initial colab-cli commit

Seth Troisi committed May 11, 2026 at 16:30 UTC 2ef9825be6e30a637f12b3631aae0d686830478f
67 files changed +11422
.githooks/pre-commit new
+9
@@ -0,0 +1,9 @@
1 +#!/usr/bin/env bash
2 +
3 +ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
4 +# end templated
5 +
6 +HERE="$(cd "$(dirname "$0")" && pwd)"
7 +ARGS+=(--hook-dir "$HERE" -- "$@")
8 +
9 +exec uv run --active --index https://pypi.org/simple --with pre-commit,pre-commit-uv pre-commit "${ARGS[@]}"
.gitignore new
+89
@@ -0,0 +1,89 @@
1 +# Byte-compiled / optimized / DLL files
2 +__pycache__/
3 +*.py[cod]
4 +*$py.class
5 +
6 +# C extensions
7 +*.so
8 +
9 +# Distribution / packaging
10 +.Python
11 +build/
12 +develop-eggs/
13 +dist/
14 +downloads/
15 +eggs/
16 +.eggs/
17 +lib/
18 +lib64/
19 +parts/
20 +sdist/
21 +var/
22 +wheels/
23 +share/python-wheels/
24 +*.egg-info/
25 +.installed.cfg
26 +*.egg
27 +MANIFEST
28 +
29 +# PyInstaller
30 +# Usually these files are written by a python script from a template
31 +# before PyInstaller builds the exe, so as to inject date/other infos into it.
32 +*.manifest
33 +*.spec
34 +
35 +# Installer logs
36 +pip-log.txt
37 +pip-delete-this-directory.txt
38 +
39 +# Unit test / coverage reports
40 +htmlcov/
41 +.tox/
42 +.nox/
43 +.coverage
44 +.coverage.*
45 +.cache
46 +nosetests.xml
47 +coverage.xml
48 +*.cover
49 +*.py,cover
50 +.hypothesis/
51 +.pytest_cache/
52 +cover/
53 +
54 +# Environments
55 +.env
56 +.venv
57 +env/
58 +venv/
59 +ENV/
60 +env.bak/
61 +venv.bak/
62 +
63 +# IDEs / Editors
64 +.vscode/
65 +.idea/
66 +*.swp
67 +*.swo
68 +*~
69 +.DS_Store
70 +
71 +# Tool caches
72 +.ruff_cache/
73 +.mypy_cache/
74 +.pyre/
75 +
76 +# Logs
77 +colab.log
78 +*.log
79 +
80 +# Jupyter
81 +.ipynb_checkpoints
82 +
83 +# Design Docs & Research Scripts (Untracked)
84 +DRIVEFS_BACKEND_PROPOSAL.md
85 +docs/AUTH_DESIGN_PROPOSAL.md
86 +docs/credential propagation - flow details.md
87 +docs/credential propagation analysis.md
88 +docs/creds.md
89 +get_drive_source.py
.pre-commit-config.yaml new
+36
@@ -0,0 +1,36 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +repos:
16 +- repo: local
17 + hooks:
18 + - id: pytest
19 + name: pytest
20 + entry: uv run pytest
21 + language: system
22 + types: [python]
23 + pass_filenames: false
24 + always_run: true
25 + - id: ruff-check
26 + name: ruff check
27 + entry: uv run ruff check --force-exclude
28 + language: system
29 + types: [python]
30 + require_serial: true
31 + - id: ruff-format
32 + name: ruff format
33 + entry: uv run ruff format --force-exclude
34 + language: system
35 + types: [python]
36 + require_serial: true
.python-version new
+1
@@ -0,0 +1 @@
1 +3.13
AGENTS.md new
+81
@@ -0,0 +1,81 @@
1 +# Colab CLI: Agent Guidelines
2 +
3 +## Architecture Overview
4 +- **CLI**: Modular `Typer` based entry point in `cli.py` with subcommands in `commands/`.
5 +- **Common**: `common.py` centralizes shared `State` (lazy-loading) and session resolution.
6 +- **Client**: `ColabClient` handles API interactions (assignment, unassignment).
7 +- **Auth**: `auth.py` exposes a single `get_credentials(config_path, provider)` facade that dispatches on the `AuthProvider` enum. Two providers are supported, selected via the global `--auth=oauth2|adc` flag (default `oauth2`):
8 + - `oauth2`: public `google-auth-oauthlib` `InstalledAppFlow`, token cached at `~/.config/colab-cli/token.json`. Requires an explicit client OAuth config (`-c/--client-oauth-config`, default `~/.colab-cli-oauth-config.json`); the previously-bundled `oauth_config.json` resource fallback was removed (commit `20eb88e`).
9 + - `adc`: Google Application Default Credentials via `google.auth.default()`. The CLI passes `scopes=PUBLIC_SCOPES` (which includes `colaboratory`) and re-applies via `creds.with_scopes()` for credential types that support it. **User credentials minted by `gcloud auth application-default login` ignore the `scopes=` kwarg AND raise `NotImplementedError` on `with_scopes`**: ADC users must explicitly re-authenticate with `gcloud auth application-default login --scopes=https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory`. **Both scopes are required**: `userinfo.email` for the session backend at `colab.research.google.com` (assign/unassign/sessions return 401 without it), and `colaboratory` for the `RuntimeService` at `colab.pa.googleapis.com` (keep-alive returns 403 without it). Service-account / GCE / GKE / impersonated creds get the right scopes transparently via `with_scopes`.
10 +- **Backend Hosts**: Two distinct backends with different requirements:
11 + - `colab.research.google.com` (session backend / `tun/m/...`): accepts the `userinfo.email` scope.
12 + - `colab.pa.googleapis.com` (`RuntimeService`, used by `KeepAliveAssignment`): requires (a) the `colaboratory` OAuth scope, AND (b) `X-Goog-Api-Client` header containing the substring `grpc-web`. Both are enforced server-side; missing either yields HTTP 400 / 403 with descriptive `google.rpc.DebugInfo` payloads. Always log `response_body` on failure for these RPCs to avoid silent debugging.
13 +- **Runtime**: `ColabRuntime` wraps `jupyter-kernel-client` for execution.
14 +- **State**:
15 + - `StateStore` persists session metadata in `~/.config/colab-cli/sessions.json`.
16 + - Persistent settings are in `~/.config/colab-cli/settings.json`.
17 +- **History**: `HistoryLogger` records structured events in `~/.config/colab-cli/history/*.jsonl`.
18 +
19 +## Core Mandates
20 +- **Minimalism**: Favor standard library where possible (e.g., `urllib`) while utilizing `Typer` for CLI ergonomics.
21 +- **Piping**: Always consider piped input (`stdin`) vs. interactive TTY.
22 +- **Trace Alignment**: When implementing new endpoints, validate against captured browser traces (HAR files).
23 +- **TDD (Test-Driven Development)**: Always implement tests first. Verify they fail before implementing the solution to make them pass. Every design must include a testing strategy and specific test cases.
24 +
25 +- **Jupyter Protocol Deviations**: Google Colab uses custom extensions to the Jupyter protocol. Examples include `colab_request` messages over the `iopub` channel and `input_reply` wrapping `colab_reply` payloads on the `stdin` channel. These require monkey-patching or specialized handlers within `jupyter-kernel-client` (e.g., `wsclient.kernel_socket.on_message` interceptors).
26 +
27 +- **Integration Testing**: Unit tests and mocks are not enough. Before declaring any feature complete, you MUST perform a real-world, end-to-end integration test against a live Colab environment using the CLI. Never rely solely on mocked unit tests to verify a feature's correctness.
28 + - Integration tests are located in `integration/` (e.g., `integration/repro_plot_redirection/test.sh`).
29 + - To run an integration test, use: `uv run bash integration/repro_<name>/test.sh`.
30 + - `uv run` ensures the `colab` command (entry point) is available in the shell environment.
31 +- **Continuous Improvement**: Whenever the user provides feedback, workflow advice, or corrections, immediately encode that advice into this `AGENTS.md` file. The goal is to learn from review and never repeat the same errors.
32 +
33 +## Tools & Workflow
34 +- **Workflow**:
35 + 1. **Draft**: Plan and start the task. Create a new git branch before working on new features or changes.
36 + 2. **Refine**: Implement changes and verify with tests and linting. Run tests using `uv run pytest tests/` and resolve any lint errors using `uv run ruff check . --fix`.
37 + 3. **Finalize**: Ensure everything is complete and correct. **Whenever features are added or behaviors change, you MUST re-review the corresponding design document in `docs/` and update it to reflect the new state. You should also add a brief log entry to the frontmatter of the updated design document with the current date summarizing the change.** Finally, commit the finished changes to the git branch for review.
38 +
39 +## Subcommand Workflows
40 +- **Session Management**: `new`, `sessions`, `status`, `stop`.
41 +- **Execution**: `repl`, `exec`, `console`.
42 +- **Files**: `ls`, `rm`, `upload`, `download`, `edit`.
43 +- **Automation**: `auth`, `drivemount`, `install`, `log`, `pay`, `version`, `update`.
44 +
45 +## Implementation Principles
46 +1. **Direct Execution**: Code for `auth`, `drivemount`, etc., should be injected and executed on the VM kernel.
47 +2. **Contents API**: Use the Jupyter Contents API for file management as seen in the browser traces.
48 +3. **Transparent Storage**: Local state must be overridable via flags.
49 +4. **No netrc**: Avoid `netrc` for token persistence in this project.
50 +5. **Mocking Interactivity**: When testing commands that branch on `stdin.isatty()`, use the `is_stdin_tty` helper in `execution.py` and mock it via `mocker.patch("colab_cli.commands.execution.is_stdin_tty", return_value=...)`. This ensures tests don't hang in CI/agent environments.
51 +6. **State Isolation**: Always patch the `colab_cli.common.state` singleton in tests to control session persistence and client behavior. Refer to `tests/conftest.py` for the standard global fixture.
52 +7. **Fire-and-Forget Architecture**: The Colab CLI is a "fire-and-forget" tool. Avoid using background threads for long-running tasks within the main command flows. For persistent needs such as keep-alive, utilize detached background daemon processes (with PID tracking in the session state).
53 +8. **Verify the Local Install**: A globally-installed `colab` may exist on `PATH` (e.g. at `~/.local/bin/colab`) and can shadow the project's editable install when `uv run` is invoked from outside the repo. ALWAYS run shell commands with the repo as the working directory (e.g. via the `workdir` parameter, never `cd && cmd`) so `uv run colab ...` resolves to `.venv/bin/colab`. Confirm with `which colab` and `uv run which colab` if a CLI test produces unexpected results (e.g. flag-not-recognized errors for flags you just added).
54 +9. **Isolate the Regression First**: When a user reports an error in code you just touched, do NOT assume your change caused it. First, reproduce the failure on `main` (or the branch point) to determine whether the bug is pre-existing. Only after confirming the regression is yours should you start debugging the new code. Encoded after spending a turn debugging "ADC broke `colab new`" only to discover `colab new --gpu A100` was already failing on `main` due to an A100-quota-vs-default issue unrelated to ADC.
55 +10. **Live Probes Allocate Real Resources**: Probing the Colab API to debug an issue creates real, billable assignments — every successful POST `/tun/m/assign` reserves a VM. Prefer GET-only (read) probes whenever possible. For any state-mutating call, (a) record every endpoint you create as you go, and (b) clean up via `client.unassign(endpoint)` (or `colab stop`) before declaring the investigation done. Then verify with `colab sessions` that nothing was orphaned.
56 +11. **Push Freshness**: The remote may have advanced during a session (other contributors land commits while you work). ALWAYS `git fetch <remote>` immediately before pushing or merging. If `git log main..<remote>/main` is non-empty, reset local `main` to the remote, rebase feature branches onto it, retest, then push. NEVER force-push `main` to recover from divergence.
57 +12. **Amend Safety**: Before `git commit --amend`, explicitly verify all three preconditions: (a) the user requested amend OR a pre-commit hook auto-modified files for an otherwise-successful commit, (b) HEAD was created by you in this conversation (`git log -1 --format='%an %ae'`), (c) the commit has not been pushed to a remote. If any precondition fails, create a new commit instead. NEVER amend a failed/rejected commit — fix the issue and create a new one.
58 +13. **Run Integration Tests Yourself**: Re-read AGENTS.md "Agent Execution Limitations" before claiming you can't run a test. The CANNOT-run list is exclusively interactive commands (`colab auth`, `colab drivemount`, `colab repl`, `colab console`). Tests built on `colab new` / `colab stop` / `colab log` are non-interactive and you MUST run them yourself before declaring a fix complete. Encoded after running through a full implement-and-document cycle for a fix that the integration test would have falsified in 30 seconds — the cure was `uv run bash integration/repro_keep_alive/test.sh`.
59 +14. **Heed Research Caveats**: When a research subagent surfaces a caveat ("the proto allows it but the policy may reject"), treat it as a TODO to verify, not as a footnote. The validation pattern: shell out to the actual service with the proposed inputs and confirm the response matches expectations BEFORE writing the code that depends on it. For policy-gated paths, run a one-shot probe before committing to a design.
60 +15. **Two distinct auths — never confuse them**: This codebase has two unrelated authentication concerns: (a) **CLI-to-Colab-control-plane**: how `Client` authenticates HTTP requests to `colab.research.google.com` and `colab.pa.googleapis.com`. Driven by the global `--auth={oauth2,adc}` flag and `auth.py:get_credentials`. The OAuth2 path is bootstrapped automatically by `google_auth_oauthlib`'s `InstalledAppFlow` on first invocation when `~/.config/colab-cli/token.json` doesn't exist; **no separate command is needed** — any `--auth=oauth2` invocation (e.g. `colab --auth=oauth2 sessions`) triggers the browser consent flow. (b) **VM-side credentials**: how the `colab auth` subcommand injects user GCP credentials *into* the running Colab kernel so notebook code (e.g. `gcloud`, BigQuery client) can make authenticated calls from inside the VM. This uses the `USE_AUTH_EPHEM='0'` gcloud-fallback path executed on the kernel via `ColabRuntime`. **NEVER tell a user to "run `colab auth`" as a prerequisite for fixing CLI-side auth issues** — they're orthogonal layers and the suggestion misleads.
61 +16. **Detached daemons inherit nothing useful**: When spawning a detached child process via `subprocess.Popen` (e.g. `spawn_keep_alive`), the child does NOT inherit the parent's parsed Typer flags. It re-parses argv from scratch, so any global flag the parent saw via `--auth=adc` or `--config /tmp/foo.json` MUST be re-emitted as part of the child's command line. Forgetting this causes silent fallback to Typer defaults: in 2026-04-30 this manifested as the keep-alive daemon (a) using OAUTH2 instead of the parent's ADC, and (b) reading from `~/.config/colab-cli/sessions.json` instead of the parent's `--config` path. Always propagate every relevant global flag in `cmd = [sys.executable, "-m", ...]` BEFORE the subcommand name (Typer requires global flags before the subcommand).
62 +17. **Persist-before-spawn for daemons that read shared state**: When a detached child reads from a state file the parent owns (e.g. `state.store.get(session_name)`), the parent MUST persist BEFORE spawning. Otherwise the child can race ahead of the parent's `add()` call and observe an empty store. Symptom in 2026-04-30: keep-alive daemon exits immediately with `keep_alive_stopped reason=session_not_found iters=1 duration=0.0s`. Fix: call `state.store.add(s)` once before `spawn_keep_alive`, and again after (to capture the PID).
63 +18. **`colab.pa.googleapis.com` rejects ADC user creds without `X-Goog-User-Project`**: When calling `colab.pa.googleapis.com` from ADC user credentials minted by `gcloud auth application-default login`, the bearer token carries the user's gcloud quota project. The colab API key sent alongside it belongs to a different project (Colab, `1014160490159`). The backend enforces project-match between the two and returns HTTP 400 `CONSUMER_INVALID` ("The API Key and the authentication credential are from different projects."). Fix: send `X-Goog-User-Project: 1014160490159` to pin the consumer project to Colab's. Any signed-in user has implicit access to the colab project via the public web client, so this works for ordinary user accounts. Service-account / GCE / GKE / impersonated creds don't hit this because their bearer token IS owned by a project that matches.
64 +19. **Pydantic validation requires a schema**: `_issue_request` accepts an optional `schema=` and historically called `TypeAdapter(schema).validate_python(...)` unconditionally. When `schema=None` (a caller that doesn't care about the response body — e.g. fire-and-forget RPCs like `KeepAliveAssignment` that return `[]`), this raises `pydantic.ValidationError: Input should be None`. Always guard with `if schema is None: return` after the empty-body short-circuit.
65 +20. **Suggest the branch-diff review command after committing**: The user reviews changes with `git diff main..<branch-name>` (full cumulative diff against `main`, not just the latest commit). After landing one or more commits on a feature branch, ALWAYS suggest the exact command — e.g. "Review with `git diff main..sort-help-commands`" — instead of `git show <sha>` (which only shows a single commit and misses context when a branch has multiple commits). Encoded 2026-05-05 after suggesting `git show 9d9c7da` for a branch the user wanted to review holistically.
66 +21. **Verify research-tool claims with primary sources**: Research tools and AI assistants can be confidently wrong, especially about edge cases or features outside their training corpus. When such a tool says "X is not used / not parsed / doesn't exist", treat it as a hypothesis to verify, not a fact. Always cross-check against the primary source (the actual code or config) — and when a tool names files, check whether the indirection chain it describes actually exists. The cost of believing the tool when it's wrong is shipping a non-functional feature; the cost of double-checking is small. Encoded 2026-05-05 after a `colab url` first-cut shipped the wrong URL format because of unverified output.
67 +22. **Clean up orphaned assignments before finishing live tests**: After running a live integration test, `colab sessions` may show server-side assignments that the local `colab stop` couldn't see (e.g. assignments leaked from earlier in the session, or from crashed prior runs). Always run `colab sessions` as the final cleanup step, and for any `[?]`-marked orphan, run `python -c "from colab_cli.common import state; state.client.unassign('<endpoint>')"` (the CLI doesn't expose a direct unassign-by-endpoint command). Re-verify with `colab sessions` returning "No active sessions". Encoded 2026-05-05 after the first `colab url` live test left an orphan from a prior conversation turn that would have idled-out and billed compute units.
68 +
69 +## Agent Execution Limitations (What I Can vs Cannot Run)
70 +As an AI agent operating via non-interactive shell tools (`run_shell_command`), there are strict limits on what I can test autonomously without human intervention:
71 +- **I CAN Run:**
72 + - Automated tests (`pytest`), linting (`ruff`), and headless execution scripts.
73 + - Subcommands that don't pause for user input (e.g., `colab new`, `colab status`, `colab stop`, `colab ls`, `colab install`, `colab exec <file.py>`).
74 + - **Piped `colab repl` and `colab console`**: as of 2026-05-07 both commands support piped stdin and exit cleanly on EOF (`echo 'cmd' | colab console -s s` returns in ~1.2s). The unpiped, interactive variants still cannot be run autonomously.
75 + - Specially crafted mock scripts that simulate timeouts or API calls.
76 +- **I CANNOT Run (Requires User Assistance):**
77 + - **`colab auth`**: This command relies on the traditional Gcloud fallback `input_request` (via `USE_AUTH_EPHEM='0'`), which prompts the user via Python's `input()` to click a URL, sign in, and paste back an authorization code. My shell tool will hang indefinitely on this `input()`.
78 + - **`colab drivemount`**: This command prompts the user via `sys.stdin.readline()` (specifically querying `/dev/tty` to ensure input is captured) to press `Enter` after granting OAuth consent in the browser. My shell tool will timeout/hang waiting for `Enter`.
79 + - **Interactive (TTY) `colab repl` / `colab console`**: When stdin is a real terminal these commands drop into interactive raw-TTY modes that require real-time keystroke streaming. My shell tools cannot support this. (Piped stdin is fine — see above.)
80 +
81 +Whenever working on interactive commands, I must build the core logic, write mock tests, and explicitly ask the user to run the live test in their terminal to verify success.
COLAB_SKILL.md new
+42
@@ -0,0 +1,42 @@
1 +# Skill: Colab Session Operator
2 +
3 +Operate Google Colab environments via the `colab` CLI: provision GPU/TPU sessions, run Python/shell on the VM, sync files, and capture work as notebooks.
4 +
5 +## When to activate
6 +- Creating or managing TPU/GPU sessions.
7 +- Running Python or shell on a remote Colab VM.
8 +- Syncing files between local and remote.
9 +- Automating environment setup (packages, auth, Drive).
10 +- Exporting session history as a Jupyter notebook.
11 +
12 +## Workflow
13 +
14 +### Provision
15 +- `colab new -s <name>` (CPU). Add `--gpu A100` or `--tpu v6e1` for accelerators.
16 +- If only one session is active, `-s` may be omitted on most command.
17 +- After `colab new`, run `colab status` to confirm the VM is responsive.
18 +
19 +### Execute
20 +- **Preferred**: `colab exec -s <name> -f <script.py>` — runs a local script on the remote VM. The kernel `cd`s to `/content` first.
21 +- **Plots**: PNG/JPEG outputs are intercepted automatically. Use `--output-image <path>` on `exec`/`repl` to save to a known location; otherwise a temp file path is printed.
22 +- **Shell**: `echo "cmd" | colab console -s <name>` works for batch shell. `exec` is faster when you don't need a real shell.
23 +- **Never run `colab repl` or `colab console` interactively from an agent** — they expect a TTY and will hang. Always pipe stdin.
24 +
25 +### Automate
26 +- `colab auth -s <name>` — needed before GCP services (GCS, BigQuery).
27 +- `colab drivemount -s <name>` — exposes `/content/drive/MyDrive`.
28 +- `colab install -s <name> pkg1 pkg2` — uses `uv` for speed.
29 +
30 +### Inspect & report
31 +- `colab help` lists every command.
32 +- `colab log -s <name> -n 20` shows recent actions; useful when a task fails.
33 +- `colab log -s <name> -o summary.ipynb` produces a notebook artifact of the session.
34 +
35 +## Safety
36 +- **Always `colab stop -s <name>` when done** — idle VMs burn compute units.
37 +- Local session state lives at `~/.config/colab-cli/sessions.json`. Don't edit by hand.
38 +- If `colab auth` fails, ask the user to verify their gcloud / OAuth credentials.
39 +
40 +## Recovery
41 +- "Session not found": the backend may have pruned it. Run `colab sessions` and re-create if needed.
42 +- Execution timeout: kernel may be deadlocked. `colab stop` then `colab new`.
CONTRIBUTING.md new
+5
@@ -0,0 +1,5 @@
1 +# Contributions
2 +
3 +We don't have the bandwidth to review external pull requests right now, and we don't want PRs to languish, so we aren't accepting external contributions at this time.
4 +
5 +If you have an idea or hit a pain point, please share it on our [discussions](https://github.com/googlecolab/google-colab-cli/discussions) page — the preferred place for issues and feature requests.
LICENSE new
+202
@@ -0,0 +1,202 @@
1 +
2 + Apache License
3 + Version 2.0, January 2004
4 + http://www.apache.org/licenses/
5 +
6 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 +
8 + 1. Definitions.
9 +
10 + "License" shall mean the terms and conditions for use, reproduction,
11 + and distribution as defined by Sections 1 through 9 of this document.
12 +
13 + "Licensor" shall mean the copyright owner or entity authorized by
14 + the copyright owner that is granting the License.
15 +
16 + "Legal Entity" shall mean the union of the acting entity and all
17 + other entities that control, are controlled by, or are under common
18 + control with that entity. For the purposes of this definition,
19 + "control" means (i) the power, direct or indirect, to cause the
20 + direction or management of such entity, whether by contract or
21 + otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 + outstanding shares, or (iii) beneficial ownership of such entity.
23 +
24 + "You" (or "Your") shall mean an individual or Legal Entity
25 + exercising permissions granted by this License.
26 +
27 + "Source" form shall mean the preferred form for making modifications,
28 + including but not limited to software source code, documentation
29 + source, and configuration files.
30 +
31 + "Object" form shall mean any form resulting from mechanical
32 + transformation or translation of a Source form, including but
33 + not limited to compiled object code, generated documentation,
34 + and conversions to other media types.
35 +
36 + "Work" shall mean the work of authorship, whether in Source or
37 + Object form, made available under the License, as indicated by a
38 + copyright notice that is included in or attached to the work
39 + (an example is provided in the Appendix below).
40 +
41 + "Derivative Works" shall mean any work, whether in Source or Object
42 + form, that is based on (or derived from) the Work and for which the
43 + editorial revisions, annotations, elaborations, or other modifications
44 + represent, as a whole, an original work of authorship. For the purposes
45 + of this License, Derivative Works shall not include works that remain
46 + separable from, or merely link (or bind by name) to the interfaces of,
47 + the Work and Derivative Works thereof.
48 +
49 + "Contribution" shall mean any work of authorship, including
50 + the original version of the Work and any modifications or additions
51 + to that Work or Derivative Works thereof, that is intentionally
52 + submitted to Licensor for inclusion in the Work by the copyright owner
53 + or by an individual or Legal Entity authorized to submit on behalf of
54 + the copyright owner. For the purposes of this definition, "submitted"
55 + means any form of electronic, verbal, or written communication sent
56 + to the Licensor or its representatives, including but not limited to
57 + communication on electronic mailing lists, source code control systems,
58 + and issue tracking systems that are managed by, or on behalf of, the
59 + Licensor for the purpose of discussing and improving the Work, but
60 + excluding communication that is conspicuously marked or otherwise
61 + designated in writing by the copyright owner as "Not a Contribution."
62 +
63 + "Contributor" shall mean Licensor and any individual or Legal Entity
64 + on behalf of whom a Contribution has been received by Licensor and
65 + subsequently incorporated within the Work.
66 +
67 + 2. Grant of Copyright License. Subject to the terms and conditions of
68 + this License, each Contributor hereby grants to You a perpetual,
69 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 + copyright license to reproduce, prepare Derivative Works of,
71 + publicly display, publicly perform, sublicense, and distribute the
72 + Work and such Derivative Works in Source or Object form.
73 +
74 + 3. Grant of Patent License. Subject to the terms and conditions of
75 + this License, each Contributor hereby grants to You a perpetual,
76 + worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 + (except as stated in this section) patent license to make, have made,
78 + use, offer to sell, sell, import, and otherwise transfer the Work,
79 + where such license applies only to those patent claims licensable
80 + by such Contributor that are necessarily infringed by their
81 + Contribution(s) alone or by combination of their Contribution(s)
82 + with the Work to which such Contribution(s) was submitted. If You
83 + institute patent litigation against any entity (including a
84 + cross-claim or counterclaim in a lawsuit) alleging that the Work
85 + or a Contribution incorporated within the Work constitutes direct
86 + or contributory patent infringement, then any patent licenses
87 + granted to You under this License for that Work shall terminate
88 + as of the date such litigation is filed.
89 +
90 + 4. Redistribution. You may reproduce and distribute copies of the
91 + Work or Derivative Works thereof in any medium, with or without
92 + modifications, and in Source or Object form, provided that You
93 + meet the following conditions:
94 +
95 + (a) You must give any other recipients of the Work or
96 + Derivative Works a copy of this License; and
97 +
98 + (b) You must cause any modified files to carry prominent notices
99 + stating that You changed the files; and
100 +
101 + (c) You must retain, in the Source form of any Derivative Works
102 + that You distribute, all copyright, patent, trademark, and
103 + attribution notices from the Source form of the Work,
104 + excluding those notices that do not pertain to any part of
105 + the Derivative Works; and
106 +
107 + (d) If the Work includes a "NOTICE" text file as part of its
108 + distribution, then any Derivative Works that You distribute must
109 + include a readable copy of the attribution notices contained
110 + within such NOTICE file, excluding those notices that do not
111 + pertain to any part of the Derivative Works, in at least one
112 + of the following places: within a NOTICE text file distributed
113 + as part of the Derivative Works; within the Source form or
114 + documentation, if provided along with the Derivative Works; or,
115 + within a display generated by the Derivative Works, if and
116 + wherever such third-party notices normally appear. The contents
117 + of the NOTICE file are for informational purposes only and
118 + do not modify the License. You may add Your own attribution
119 + notices within Derivative Works that You distribute, alongside
120 + or as an addendum to the NOTICE text from the Work, provided
121 + that such additional attribution notices cannot be construed
122 + as modifying the License.
123 +
124 + You may add Your own copyright statement to Your modifications and
125 + may provide additional or different license terms and conditions
126 + for use, reproduction, or distribution of Your modifications, or
127 + for any such Derivative Works as a whole, provided Your use,
128 + reproduction, and distribution of the Work otherwise complies with
129 + the conditions stated in this License.
130 +
131 + 5. Submission of Contributions. Unless You explicitly state otherwise,
132 + any Contribution intentionally submitted for inclusion in the Work
133 + by You to the Licensor shall be under the terms and conditions of
134 + this License, without any additional terms or conditions.
135 + Notwithstanding the above, nothing herein shall supersede or modify
136 + the terms of any separate license agreement you may have executed
137 + with Licensor regarding such Contributions.
138 +
139 + 6. Trademarks. This License does not grant permission to use the trade
140 + names, trademarks, service marks, or product names of the Licensor,
141 + except as required for reasonable and customary use in describing the
142 + origin of the Work and reproducing the content of the NOTICE file.
143 +
144 + 7. Disclaimer of Warranty. Unless required by applicable law or
145 + agreed to in writing, Licensor provides the Work (and each
146 + Contributor provides its Contributions) on an "AS IS" BASIS,
147 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 + implied, including, without limitation, any warranties or conditions
149 + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 + PARTICULAR PURPOSE. You are solely responsible for determining the
151 + appropriateness of using or redistributing the Work and assume any
152 + risks associated with Your exercise of permissions under this License.
153 +
154 + 8. Limitation of Liability. In no event and under no legal theory,
155 + whether in tort (including negligence), contract, or otherwise,
156 + unless required by applicable law (such as deliberate and grossly
157 + negligent acts) or agreed to in writing, shall any Contributor be
158 + liable to You for damages, including any direct, indirect, special,
159 + incidental, or consequential damages of any character arising as a
160 + result of this License or out of the use or inability to use the
161 + Work (including but not limited to damages for loss of goodwill,
162 + work stoppage, computer failure or malfunction, or any and all
163 + other commercial damages or losses), even if such Contributor
164 + has been advised of the possibility of such damages.
165 +
166 + 9. Accepting Warranty or Additional Liability. While redistributing
167 + the Work or Derivative Works thereof, You may choose to offer,
168 + and charge a fee for, acceptance of support, warranty, indemnity,
169 + or other liability obligations and/or rights consistent with this
170 + License. However, in accepting such obligations, You may act only
171 + on Your own behalf and on Your sole responsibility, not on behalf
172 + of any other Contributor, and only if You agree to indemnify,
173 + defend, and hold each Contributor harmless for any liability
174 + incurred by, or claims asserted against, such Contributor by reason
175 + of your accepting any such warranty or additional liability.
176 +
177 + END OF TERMS AND CONDITIONS
178 +
179 + APPENDIX: How to apply the Apache License to your work.
180 +
181 + To apply the Apache License to your work, attach the following
182 + boilerplate notice, with the fields enclosed by brackets "[]"
183 + replaced with your own identifying information. (Don't include
184 + the brackets!) The text should be enclosed in the appropriate
185 + comment syntax for the file format. We also recommend that a
186 + file or class name and description of purpose be included on the
187 + same "printed page" as the copyright notice for easier
188 + identification within third-party archives.
189 +
190 + Copyright [yyyy] [name of copyright owner]
191 +
192 + Licensed under the Apache License, Version 2.0 (the "License");
193 + you may not use this file except in compliance with the License.
194 + You may obtain a copy of the License at
195 +
196 + http://www.apache.org/licenses/LICENSE-2.0
197 +
198 + Unless required by applicable law or agreed to in writing, software
199 + distributed under the License is distributed on an "AS IS" BASIS,
200 + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 + See the License for the specific language governing permissions and
202 + limitations under the License.
README.md new
+92
@@ -0,0 +1,92 @@
1 +# Colab CLI
2 +
3 +A command-line interface for Google Colab. Create sessions, run code, manage files, and capture work — all without leaving your terminal.
4 +
5 +> Why? The agents are coming.
6 +
7 +## Install
8 +
9 +```bash
10 +uv tool install colab
11 +```
12 +
13 +## Quick start
14 +
15 +```bash
16 +colab new # provision a CPU session
17 +echo "print('hello')" | colab exec # run code
18 +colab stop # release the VM
19 +```
20 +
21 +When only one session is active you can omit `-s <session>`; the CLI selects it automatically.
22 +
23 +## Commands
24 +
25 +### Sessions
26 +| Command | Description |
27 +| --- | --- |
28 +| `colab new [-s NAME] [--gpu T4\|L4\|A100\|H100] [--tpu v5e1\|v6e1]` | Provision a new session (CPU by default) |
29 +| `colab sessions` | List all active sessions on the backend |
30 +| `colab status [-s NAME]` | Show one session, or all locally-known sessions |
31 +| `colab stop -s NAME` | Terminate a session |
32 +| `colab url [-s NAME] [--open]` | Print a browser URL that opens the session in Colab |
33 +
34 +### Execution
35 +| Command | Description |
36 +| --- | --- |
37 +| `colab exec [-s NAME] [-f FILE] [--output-image PATH]` | Run Python from stdin, a `.py` file, or a `.ipynb` notebook |
38 +| `colab repl [-s NAME] [--output-image PATH]` | Interactive Python REPL (or one-shot if stdin is piped) |
39 +| `colab console [-s NAME]` | Raw TTY shell on the VM (or one-shot if stdin is piped) |
40 +
41 +### Files
42 +| Command | Description |
43 +| --- | --- |
44 +| `colab ls [-s NAME] [PATH]` | List remote files |
45 +| `colab upload -s NAME LOCAL REMOTE` | Upload a file |
46 +| `colab download -s NAME REMOTE LOCAL` | Download a file |
47 +| `colab rm -s NAME PATH` | Delete a remote file |
48 +| `colab edit -s NAME PATH` | Edit a remote file in `$EDITOR` |
49 +
50 +### Automation & utility
51 +| Command | Description |
52 +| --- | --- |
53 +| `colab auth -s NAME` | Authenticate the VM for GCP services |
54 +| `colab drivemount -s NAME [PATH]` | Mount Google Drive (default `/content/drive`) |
55 +| `colab install -s NAME [-r requirements.txt \| pkg ...]` | Install packages with `uv` (falls back to `pip`) |
56 +| `colab log [-s NAME] [-n N] [-o FILE]` | View or export session history (`.ipynb`/`.md`/`.txt`/`.jsonl`) |
57 +| `colab pay` | Open the Colab signup page |
58 +| `colab version` | Print the installed version |
59 +| `colab update [--install]` | Check for a newer release (and optionally install it) |
60 +| `colab help` | Show usage |
61 +
62 +### Global options
63 +- `--auth {oauth2,adc}` — authentication strategy (default `oauth2`)
64 +- `-c, --client-oauth-config PATH` — OAuth client config (default `~/.colab-cli-oauth-config.json`)
65 +- `--config PATH` — session state file (default `~/.config/colab-cli/sessions.json`)
66 +- `--logtostderr` — send all output to stderr
67 +
68 +## Examples
69 +
70 +```bash
71 +# Train a model on an A100, save the checkpoint locally
72 +colab new -s trainer --gpu A100
73 +colab install -s trainer torch transformers
74 +colab exec -s trainer -f train.py
75 +colab download -s trainer checkpoints/model.bin ./model.bin
76 +colab stop -s trainer
77 +
78 +# Mount Drive and analyze a notebook
79 +colab new -s analysis
80 +colab drivemount -s analysis
81 +colab exec -s analysis -f analysis.ipynb # writes analysis_output.ipynb
82 +colab log -s analysis -o report.ipynb
83 +colab stop -s analysis
84 +```
85 +
86 +## Notes
87 +
88 +- `repl` and `console` require a TTY when run interactively. Pipe stdin to use them in scripts.
89 +- `exec` reads files locally and ships their contents to the VM — local edits don't require uploading.
90 +- Session metadata is stored at `~/.config/colab-cli/sessions.json`. Settings (auto-update etc.) live at `~/.config/colab-cli/settings.json`.
91 +
92 +See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for how to file feedback.
docs/01_session_management.md new
+102
@@ -0,0 +1,102 @@
1 +# Design: Session Management (`new`, `status`, `stop`, `sessions`)
2 +
3 +## Overview
4 +Session management involves interacting with the Colab backend to allocate, monitor, and terminate runtimes.
5 +
6 +## Runtime Parameters
7 +
8 +The `colab new` command supports selecting specific hardware and runtime environments. Based on the `tpu-v5e1.har` trace and `colab-agent` source code, the following parameters and values are identified:
9 +
10 +### 1. Variants (`variant`)
11 +Defines the general class of hardware requested.
12 +- `DEFAULT`: Standard CPU-based runtime.
13 +- `GPU`: Request a GPU-accelerated runtime.
14 +- `TPU`: Request a TPU-accelerated runtime.
15 +
16 +### 2. Accelerators (`accelerator`)
17 +Defines the specific hardware model.
18 +- **None**: For `DEFAULT` variant.
19 +- **GPU Accelerators**:
20 + - `T4`: NVIDIA T4 (standard free-tier GPU).
21 + - `L4`: NVIDIA L4 (cost-effective modern GPU).
22 + - `A100`: NVIDIA A100 (high-performance GPU).
23 + - `H100`: NVIDIA H100 (latest-gen performance GPU).
24 +- **TPU Accelerators**:
25 + - `V2-8`: TPU v2 (8 cores).
26 + - `V5E1`: TPU v5e (1 core, optimized for inference/efficient training).
27 + - `V6E1`: TPU v6e (1 core, high performance).
28 +
29 +### 3. CLI Mapping
30 +The CLI maps user flags to these backend parameters:
31 +- `colab new my-session` -> `variant=DEFAULT`, `accelerator=NONE`
32 +- `colab new my-session -gpu=L4` -> `variant=GPU`, `accelerator=L4`
33 +- `colab new my-session -tpu=v5e1` -> `variant=TPU`, `accelerator=V5E1`
34 +
35 +## Approach
36 +
37 +### 1. New Session (`colab new`)
38 +- **API**: `GET https://colab.sandbox.google.com/tun/m/assign` (based on HAR).
39 +- **Parameters**:
40 + - `nbh`: Notebook hash. Generated from a unique UUID per CLI session/client instance, transformed to web-safe base64 with specific padding (44 characters total).
41 + - `nsa`: 1 (Standard flag observed in browser traces, typically for "next-gen session architecture").
42 + - `variant`: Selected from the list above.
43 + - `accelerator`: Selected from the list above.
44 +- **State Persistence**: The response contains a `token` and potentially a backend URL or identifier. We will store this in a local JSON file (default `~/.config/colab-cli/sessions.json`).
45 + - Format: `{ "session_name": { "token": "...", "backend_url": "...", "hardware": "..." } }`
46 +
47 +### 2. Session Status (`colab status`)
48 +- **API**: `/api/sessions` or querying the kernel for resource usage via a special "status" message.
49 +- **Metric Collection**: Execute a small snippet on the VM to get memory/CPU usage if the backend API doesn't provide it directly.
50 +
51 +### 3. Stop Session (`colab stop`)
52 +- **API**: `POST https://colab.sandbox.google.com/tun/m/unassign/<endpoint>` (based on `tpu-v5e1-unassign.har`).
53 +- **Flow**:
54 + 1. Perform a `GET` request to the unassign URL to obtain a fresh XSRF token.
55 + 2. Perform a `POST` request to the same URL with the `X-Goog-Colab-Token` header.
56 +- **Parameters**:
57 + - `authuser`: 0.
58 + - `<endpoint>`: The unique session identifier returned during assignment (e.g., `tpu-v5e1-s-kkb-...`).
59 +- **Cleanup**: Remove the session from the local state file upon successful 204 response.
60 +
61 +### 4. Session Listing (`colab sessions`)
62 +- **API**: `GET https://colab.research.google.com/tun/m/assignments` (based on `colab-agent` implementation).
63 +- **Function**: Lists all active VM assignments for the user. This is useful for synchronizing local state with actual backend sessions.
64 +
65 +### 5. Keep-Alive Protocol
66 +To prevent Colab VMs from being deleted due to idle timeouts (standard is ~90 minutes), the CLI implements a background keep-alive mechanism.
67 +- **Daemon Process**: Since the CLI is a fire-and-forget tool, `colab new` spawns a detached background process running a hidden `keep-alive` command.
68 +- **RPC**: Every 60 seconds, the daemon calls `google.internal.colab.v1.RuntimeService/KeepAliveAssignment` at `colab.pa.googleapis.com`. The wire format is grpc-web JSON: `Content-Type: application/json+protobuf`, body `["<endpoint>"]` (positional protojson), `X-Goog-Api-Client: grpc-web/0.1`, `x-user-agent: grpc-web-javascript/0.1`. **Both** the `colaboratory` OAuth scope (see `04_automation_and_utility.md`) and the `grpc-web` substring in `X-Goog-Api-Client` are server-enforced; missing either yields a descriptive 403/400 response.
69 +- **Pre-flight (`colab new`, OAuth2/ADC only)**: Immediately after a successful `assign`, the CLI invokes `keep_alive_assignment` once synchronously. If the response is 403 with a `SCOPE_NOT_PERMITTED` body, it unassigns the new VM (to avoid leaking a billable assignment) and prints a per-provider remediation message before exiting non-zero. Other errors are tolerated — the daemon will retry and surface them via the structured event log.
70 +- **Structured logging**: The daemon emits `keep_alive_started` (with `pid`, `endpoint`), one `keep_alive_error` per failed iteration (with `status_code`, `error_type`, truncated `error`, `response_body`, `iteration`, `consecutive_4xx`), and `keep_alive_stopped` (with `reason`, `iterations`, `duration_seconds`, optional `last_error`, optional `expected_endpoint`/`actual_endpoint`). All three are rendered specially by `colab log` so users get diagnostic context without parsing JSONL by hand.
71 +- **Termination**:
72 + - **Explicit**: `colab stop` terminates the daemon using its stored PID.
73 + - **Implicit**: If a session is pruned (e.g., during `sync_sessions`), its daemon is also terminated.
74 + - **Safety Fallback**: The daemon automatically terminates after 24 hours to prevent permanent zombie processes.
75 + - **State Check**: The daemon periodically verifies that its session still exists in the local state store; if missing, it exits.
76 + - **Repeated 4xx**: After two consecutive 4xx responses, the daemon exits with `reason=consecutive_4xx_errors`. The pre-flight in `colab new` now catches the most common cause (missing `colaboratory` scope) before it reaches this branch.
77 +
78 +## TODO / Future Work
79 +- **Backend Sync**: Implement a way to reconcile the local `sessions.json` with the output of `colab sessions`.
80 +- **Resource Usage**: Add real-time resource usage (CPU/RAM/GPU) to the `status` output by executing a diagnostic snippet on the VM.
81 +
82 +## Implementation Details
83 +- **Authentication**: Uses `google-auth-oauthlib` to perform a local server OAuth flow.
84 +- **Global Flags**:
85 + - `-c`, `--client-oauth-config`: Path to the client secrets JSON file (default: `~/.colab-cli-oauth-config.json`).
86 + - `--config`: Path to the session state JSON file (default: `~/.config/colab-cli/sessions.json`).
87 +- **Token Storage**: Credentials are persisted to `~/.config/colab-cli/token.json` after the initial flow.
88 +- Use `requests` for robust HTTP interactions and `pydantic` for schema validation.
89 +- Handle authentication headers (likely `Authorization: Bearer <token>` or cookies).
90 +
91 +## Testing Strategy
92 +TDD is mandatory for all session management features.
93 +
94 +### 1. Mock Assignment API
95 +- **Test Case**: Verify `colab new` correctly parses a `PostAssignmentResponse` and stores it in the local `StateStore`.
96 +- **Test Case**: Verify `colab stop` sends a `POST` request with the correct XSRF token to the unassign endpoint.
97 +- **Test Case**: Verify that the path provided via `-c` is correctly passed to the authentication flow.
98 +- **Mocking**: Use `unittest.mock` to intercept `requests.Session.request` and return simulated XSSI-prefixed JSON payloads matching the HAR traces.
99 +
100 +### 2. State Store Validation
101 +- **Test Case**: Verify `StateStore` correctly handles file locking and multiple concurrent reads/writes.
102 +- **Test Case**: Verify `--config` override correctly directs all operations to the specified file path.
docs/02_execution_and_interactive.md new
+54
@@ -0,0 +1,54 @@
1 +---
2 +log:
3 +2026-05-07: Fixed `colab console` piped-stdin handling. Previously a piped invocation (e.g. `echo 'cmd' | colab console -s s`) sent the command and then hung indefinitely because the previous EOF handler emitted a bare `\x04` (Ctrl-D), which the remote `tmux`-wrapped bash treats as a literal character rather than a session terminator. The new handler sends `exit\n` (which bash actually exits on) and then closes the websocket from the client side after a short grace period (`PIPED_EOF_GRACE_SECONDS = 0.5s`) so any tail output (bash `logout`, tmux `[exited]`) makes it back to the user. TTY mode is unchanged: real-terminal EOF is left to the remote shell. Verified live: `echo 'echo HELLO' | colab console -s s` now exits in ~1.2s instead of hanging.
4 +
5 +2026-05-07: Fixed `print_kitty` (used by `colab exec --output-image` and any image-producing exec) to no-op when `sys.stdout.isatty()` is false. The Kitty Graphics Protocol escape sequence is meaningless when stdout is a file or pipe and was visually corrupting captured output (a multi-KB base64 PNG blob would land in log files, grep targets, or showboat captures). Image bytes are still saved to disk via `handle_image`'s file-write path; only the inline-render attempt is suppressed.
6 +---
7 +
8 +# Design: Execution and Interactive Interaction (`repl`, `exec`, `console`)
9 +
10 +## Overview
11 +Execution involves sending Python code (or shell commands) to the Jupyter kernel running on the Colab VM and processing the stream of output messages.
12 +
13 +## Approach
14 +
15 +### 1. REPL (`colab repl`)
16 +- **Transport**: WebSockets (using `websockets` library if allowed, or a custom `http.client` based long-polling implementation if we're strictly stdlib).
17 +- **Communication**: Jupyter Kernel Messaging Protocol.
18 + - `execute_request`: Send code string.
19 + - `execute_reply`: Get status.
20 + - `iopub.stream`: Capture `stdout` and `stderr`.
21 +- **Interactive Mode**: Standard Python `cmd.Cmd` or `code.InteractiveConsole` for local input/output.
22 +- **Piping Support**: Detect `sys.stdin.isatty()`. If not a TTY, read all input and send as a single execution request.
23 +
24 +### 2. Execution (`colab exec`)
25 +- **File Handling**:
26 + - If file path is local: Read content, send as code.
27 + - If file path is remote: Execute `!python <path>`.
28 +- **Multi-Modal Output**: Handle `display_data` messages (e.g., `image/png`, `text/html`). For the CLI, we'll save images to temporary files and print their paths, or if the terminal supports it (e.g., iTerm2), inline them.
29 +
30 +### 3. Console (`colab console`)
31 +- **Implementation**: Connects directly to the backend terminal endpoint (`/colab/tty`) via WebSockets using `websocket-client`.
32 +- **Interactive**: Bypasses the Jupyter kernel entirely to provide a raw, PTY-backed bash session on the Colab VM.
33 +- **Terminal Management**: Configures `sys.stdin` to raw mode using `termios` and `tty`, passing single characters to the socket and writing raw ANSI escape sequences directly to `sys.stdout.buffer`. Hooks into `SIGWINCH` to communicate local terminal dimensions (`cols`/`rows`) to the remote bash environment so output rendering works perfectly during resizing.
34 +- **Piped stdin**: Detected via `sys.stdin.isatty()`. When piped, the input characters are forwarded one at a time to the remote pty, and on EOF the client sends `exit\n` and then closes the websocket itself after `PIPED_EOF_GRACE_SECONDS` (0.5s) so the user's shell goodbye text drains back. The remote `/colab/tty` endpoint wraps bash in tmux, which intercepts a bare `\x04` as a literal character — that is why we send `exit\n` rather than Ctrl-D.
35 +
36 +## Implementation Details
37 +- **Kernel Management**: `ColabRuntime` (from `colab-agent`) already handles message signing and message types.
38 +- **Output Streaming**: Continuous polling or asynchronous message handling to provide real-time output.
39 +- **Piping Example**: `cat script.py | colab exec -s my-session`.
40 +
41 +## Testing Strategy
42 +TDD is mandatory for all execution features.
43 +
44 +### 1. Mock Kernel Client
45 +- **Test Case**: Verify `ColabRuntime` correctly sends an `execute_request` message over the websocket.
46 +- **Test Case**: Verify `iopub.stream` messages are correctly handled and printed to `stdout` in real-time.
47 +- **Test Case**: Verify `display_data` (specifically `image/png`) triggers the correct local handling (saving or display).
48 +
49 +### 2. TTY and Piping
50 +- **Test Case**: Mock `sys.stdin.isatty()` to verify `colab repl` correctly switches between interactive mode and one-shot piped execution.
51 +- **Test Case**: Verify large piped inputs are handled without buffer overflow or truncation.
52 +- **Test Case**: `colab console` with piped stdin sends `exit\n` and calls `ws.close()` on EOF (regression: previously sent `\x04` only and hung).
53 +- **Test Case**: `colab console` in TTY mode does not synthesize an exit on EOF (the user owns the session lifecycle).
54 +- **Test Case**: `print_kitty` is a no-op when `sys.stdout.isatty()` is false (regression: previously emitted ANSI/base64 into pipes and files).
docs/03_file_management.md new
+62
@@ -0,0 +1,62 @@
1 +# Design: File Management (`ls`, `rm`, `upload`, `download`, `edit`)
2 +
3 +## Overview
4 +File management on the Colab VM will be implemented using the Jupyter Contents API.
5 +
6 +## Approach
7 +
8 +### 1. Listing Files (`colab ls`)
9 +- **API**: `GET /api/contents/<path>` (as seen in HAR L68181).
10 +- **Parameters**:
11 + - `authuser`: 0
12 + - `colab-runtime-proxy-token`: <session_token>
13 +- **Response**: JSON with `content` field containing an array of directory entries.
14 +- **Display**: Pretty-print the list (similar to `ls -F` or a formatted table).
15 +
16 +### 2. Uploading Files (`colab upload`)
17 +- **API**: `PUT /api/contents/<remote_path>` (as seen in HAR).
18 +- **Payload**: JSON body:
19 + ```json
20 + {
21 + "name": "filename.txt",
22 + "path": "path/filename.txt",
23 + "type": "file",
24 + "format": "text",
25 + "content": "..."
26 + }
27 + ```
28 +- **Base64 Encoding**: Use `format: base64` for binary files.
29 +- **Progress**: Implement a simple progress bar for large uploads by chunking or providing status updates.
30 +
31 +### 3. Downloading Files (`colab download`)
32 +- **API**: `GET /api/contents/<remote_path>?content=1` (as seen in HAR).
33 +- **Response**: JSON with `content` field.
34 +- **Handling**: Decodes content based on `format` (text or base64) and saves it locally.
35 +
36 +### 4. Deleting Files (`colab rm`)
37 +- **API**: `DELETE /api/contents/<remote_path>`.
38 +
39 +### 5. Editing Files (`colab edit`)
40 +- **Approach**: Combines downloading the remote file, opening it in the user's `$EDITOR` locally, and subsequently uploading the changed file if modifications were made.
41 +- **State tracking**: Uses a SHA-256 hash to track file changes securely and deterministically between before and after the editor is invoked.
42 +- **Fallbacks**: Creates an empty local temporary file if the target file on the Colab runtime doesn't exist yet, essentially acting like `touch`.
43 +
44 +## Implementation Details
45 +- **Base URL**: The backend URL obtained during session assignment.
46 +- **Proxy Token**: The `colab-runtime-proxy-token` is required for each request.
47 +- **Error Handling**: Handle 404 (not found) and 403 (unauthorized).
48 +- **Large Files**: The Contents API might have limitations for very large files. If so, we'll implement a fallback via the kernel (streaming chunks).
49 +
50 +## Testing Strategy
51 +TDD is mandatory for all file management features.
52 +
53 +### 1. Mock Contents API
54 +- **Test Case**: Verify `colab ls` correctly parses a Jupyter `contents` JSON response with `type: directory` and `type: file`.
55 +- **Test Case**: Verify `colab upload` correctly base64-encodes a binary local file for the `PUT` payload.
56 +- **Test Case**: Verify `colab download` correctly decodes the `content` field from the `GET` response and saves it locally.
57 +- **Test Case**: Verify `colab edit` safely handles when a file is or isn't modified.
58 +- **Test Case**: Verify `colab edit` securely opens a system editor safely through mocks without hanging the testing environment.
59 +
60 +### 2. Error Cases
61 +- **Test Case**: Verify 404 responses are correctly caught and presented as a "File not found" error to the user.
62 +- **Test Case**: Verify correct handling of large file uploads exceeding API limits via kernel streaming.
\ No newline at end of file
docs/04_automation_and_utility.md new
+271
@@ -0,0 +1,271 @@
1 +---
2 +log:
3 +2026-05-07: Added a developer-only `colab whoami` subcommand (hidden from `colab --help`). Mints an access token via the same `auth.get_credentials(...)` path the rest of the CLI uses (honoring the global `--auth=...` flag), refreshes the credentials, then queries `https://oauth2.googleapis.com/tokeninfo` to print the email, scopes, audience, and expiry of whatever the CLI is about to send. Built specifically to short-circuit the "why is my call to colab.pa.googleapis.com 403-ing" debugging loop — the answer is almost always "missing scope" or "wrong identity", both of which `whoami` makes immediately visible. Hidden via `app.command(hidden=True)`; reachable via `colab whoami` or `colab whoami --help`. Suppressed from the daily-update banner check (added to `_AUTO_UPDATE_SUPPRESSED` in `cli.py`) so the banner doesn't obscure the auth output.
4 +2026-05-11: Removed the local-file update source (`update_file_path` setting and `_fetch_local` helper); `colab update` now consults PyPI only. Switched the default `update_url` to the canonical PyPI JSON API (`https://pypi.org/pypi/google-colab-cli/json`), which already exposes the `info.version` schema the auto-update subsystem expects. Re-added `colab update --install` as a public self-install path that runs `pip install -U google-colab-cli` against the current `sys.executable`; Linux-only (other platforms exit non-zero with an explanatory message), and a silent no-op when the cached `latest_version` is already at or below the current install.
5 +---
6 +
7 +# Design: Automation and Utility (`auth`, `install`, `log`, `pay`, `version`, `update`, `whoami`)
8 +
9 +## Overview
10 +
11 +These subcommands are implemented by executing Python code on the Colab VM,
12 +managing local state, or inspecting the environment.
13 +
14 +## Authentication Strategies (CLI Backend)
15 +
16 +The CLI supports two authentication strategies for talking to the Colab
17 +backend, selected via the global `--auth=<provider>` flag:
18 +
19 +1. **`oauth2`** (default): Standard public InstalledAppFlow via
20 + `google-auth-oauthlib`. Opens a browser for consent, caches the refresh
21 + token at `~/.config/colab-cli/token.json`. Requires a client OAuth
22 + config at `~/.colab-cli-oauth-config.json` or a path passed via
23 + `-c/--client-oauth-config`.
24 +2. **`adc`**: Application Default Credentials via `google.auth.default()`.
25 + Honors the standard ADC discovery chain
26 + (`GOOGLE_APPLICATION_CREDENTIALS`, `gcloud auth application-default
27 + login`, GCE/GKE metadata server). Useful when running the CLI from
28 + environments that already have ambient Google credentials.
29 +
30 +The choices are encoded as the `AuthProvider` string-enum in `auth.py`. The
31 +`get_credentials(config_path, provider)` entry point dispatches on this enum,
32 +allowing the core `Client` to remain authentication-agnostic — it only sees a
33 +`requests.AuthorizedSession`.
34 +
35 +### Required Scopes
36 +
37 +The CLI talks to two distinct backends, each with different scope demands:
38 +
39 +- `colab.research.google.com` (session assignment / unassignment /
40 + contents API): the `userinfo.email` scope is sufficient.
41 +- `colab.pa.googleapis.com` (`RuntimeService`, used by
42 + `KeepAliveAssignment`): **requires** the
43 + `https://www.googleapis.com/auth/colaboratory` scope. Without it, every
44 + request returns HTTP 403 with body `[7,"Request had insufficient
45 + authentication scopes.",...]` and a `DebugInfo` mentioning
46 + `SCOPE_NOT_PERMITTED`. (The frontend additionally requires
47 + `X-Goog-Api-Client` to contain `grpc-web` — see
48 + `01_session_management.md` §5.)
49 +
50 +How each provider supplies the scope:
51 +
52 +- **`oauth2`**: `PUBLIC_SCOPES` already includes `colaboratory`, so the
53 + InstalledAppFlow consent screen lists it. Existing cached tokens at
54 + `~/.config/colab-cli/token.json` that were minted before this change must
55 + be deleted to trigger a fresh consent flow.
56 +- **`adc`**: `google.auth.default(scopes=PUBLIC_SCOPES)` is called, and for
57 + credential subclasses that support `with_scopes` (service accounts,
58 + GCE/GKE metadata, impersonated) we re-apply via `creds.with_scopes(...)`.
59 + User credentials from `gcloud auth application-default login` ignore the
60 + `scopes=` kwarg AND raise `NotImplementedError` on `with_scopes`; those
61 + users must explicitly re-authenticate:
62 +
63 + ```
64 + gcloud auth application-default login \
65 + --scopes=https://www.googleapis.com/auth/userinfo.email,\
66 + https://www.googleapis.com/auth/colaboratory
67 + ```
68 +
69 + Both scopes are required: `userinfo.email` for the session backend at
70 + `colab.research.google.com` (otherwise assign/unassign/sessions return
71 + HTTP 401), and `colaboratory` for the `RuntimeService` at
72 + `colab.pa.googleapis.com` (otherwise keep-alive returns HTTP 403).
73 +
74 +`colab new` performs a one-shot keep-alive pre-flight after `assign`
75 +succeeds so missing-scope failures surface immediately (with per-provider
76 +remediation guidance) rather than silently after ~1 minute via the daemon.
77 +
78 +## Approach
79 +
80 +### 1. Authentication (`colab auth`)
81 +
82 +- **Action**: Execute code on the VM to trigger user-interactive
83 + authentication using the classic Gcloud fallback.
84 +- **Code**: `python import os os.environ['USE_AUTH_EPHEM'] = '0' from
85 + google.colab import auth auth.authenticate_user()`
86 +- **Handling**: Setting `USE_AUTH_EPHEM` to `'0'` forces the kernel to print a
87 + standard `gcloud` verification URL and trigger an `input_request` message on
88 + the `iopub` channel. The CLI intercepts this via a `stdin_hook` and prompts
89 + the user locally, returning the code to unlock the kernel.
90 +
91 +### 2. Package Installation (`colab install`)
92 +
93 +- **Action**: Execute `pip` on the VM.
94 +- **Code**: `python import sys, subprocess
95 + subprocess.check_call([sys.executable, "-m", "pip", "install", "..."])`
96 +- **Requirements File**: Upload `requirements.txt` if provided with `-r` and
97 + then run `pip install -r`.
98 +
99 +### 3. Drive Mounting (`colab drivemount`)
100 +
101 +- **Action**: Execute `drive.mount()` and transparently proxy Colab's
102 + proprietary credential propagation flow.
103 +- **Code**: `python from google.colab import drive
104 + drive.mount('/content/drive')`
105 +- **Handling**: Because `drivefs` enforces the ephemeral side-channel
106 + propagation (`colab_request` over websocket), the CLI intercepts these
107 + messages using `ColabRuntime.colab_request_hook`. When intercepted, the CLI
108 + automatically interacts with the Colab backend
109 + (`/tun/m/credentials-propagation/`), prompts the user with the Google OAuth
110 + consent URL if needed, and dispatches the required `colab_reply` message to
111 + the `stdin` channel to unlock the kernel thread.
112 +
113 +### 4. Logging and Notebook Capture (`colab log`)
114 +
115 +- **Action**: Capture the session's command history and outputs.
116 +- **Storage**: Maintain a local JSON-L file of all major operations,
117 + executions, and stdin interactions in
118 + `~/.config/colab-cli/history/<session_name>.jsonl`.
119 +- **Viewing**: `colab log list` and `colab log show <session>`.
120 +- **Conversion (Planned)**: Future expansion to convert history logs to
121 + `.ipynb` or `.html`.
122 +
123 +### 5. Subscription Management (`colab pay`)
124 +
125 +- **Action**: Open the Colab signup page in the user's browser.
126 +- **Implementation**: Uses
127 + `webbrowser.open("https://colab.research.google.com/signup")`.
128 +
129 +### 6. Version Information (`colab version`)
130 +
131 +- **Action**: Show the current version of the Colab CLI.
132 +- **Implementation**:
133 + - Attempts to retrieve the version using
134 + `importlib.metadata.version("colab")`.
135 + - If not installed (e.g., running from source), it falls back to the short
136 + Git commit hash using `git rev-parse --short HEAD`.
137 + - Dynamic versioning is supported in the build system via `hatch-vcs`.
138 +
139 +### 7. Auto-Update (`colab update`)
140 +
141 +- **Action**: Check if a new version of the Colab CLI is available.
142 +- **Auto-check**: The CLI automatically checks for updates once every 24 hours
143 + during the execution of any command. Independently, the cached
144 + `latest_version` (see below) is consulted on **every** invocation so the
145 + upgrade banner remains visible between fetches without requiring a network
146 + round-trip.
147 +- **Suppressed subcommands**: To keep machine-parseable output clean, the
148 + daily fetch and the cached banner are suppressed for `update` (which
149 + runs its own check), `version`, `log`, `pay`, `url`, `help`, and
150 + `whoami`. The list lives as `_AUTO_UPDATE_SUPPRESSED` in the global
151 + Typer callback in `cli.py`.
152 +- **Manual-check**: `colab update` forces a check and prints the status.
153 +- **Implementation**:
154 + - Fetches a PyPI-style JSON document from a configurable `update_url`
155 + (default: `https://pypi.org/pypi/google-colab-cli/json`) and reads
156 + `info.version`.
157 + - Compares the fetched version with the current CLI version using
158 + PEP 440 / semantic versioning, falling back to string equality when a
159 + version is unparseable.
160 + - Persists the following fields in `~/.config/colab-cli/settings.json`:
161 + - `update_url`: source configuration.
162 + - `last_check`: timestamp of the last fetch (drives the daily
163 + throttle).
164 + - `enable_update_check`: master switch for both the daily fetch and
165 + the cached banner.
166 + - `latest_version`: highest version observed during the most
167 + recent successful check. Updated whenever a strictly-newer
168 + version is observed (never downgraded), and preserved verbatim
169 + across failed checks so transient network issues do not erase
170 + the cache.
171 +- **Notification**: If a new version is found, a non-intrusive message is
172 + printed to the console with a `Run 'pip install --upgrade colab' to
173 + update.` hint. The cached banner shown between fetches uses the generic
174 + `Run 'colab update' to update.` hint.
175 +- **Self-install (`--install`)**: An opt-in `--install` flag (default
176 + `False`) makes `colab update` shell out to `pip install -U
177 + google-colab-cli` (using `sys.executable` so the upgrade lands in the
178 + same interpreter the CLI is running under). **Linux only**; on other
179 + platforms the command exits non-zero with an explanatory message. When
180 + the cached `latest_version` is already at or below the current install,
181 + the flag is a silent no-op so it is safe to wire into automation. If
182 + `pip` exits non-zero, `colab update --install` propagates the same
183 + exit code.
184 +
185 +### 8. Identity Inspection (`colab whoami`) [developer-only]
186 +
187 +- **Action**: Resolve the active credentials, mint an access token, and
188 + print the email, audience, scopes, and expiry of that token.
189 +- **Visibility**: Registered with `hidden=True` so it does not appear in
190 + `colab --help`. Discoverable via source code, `colab whoami --help`, or
191 + word-of-mouth. The intent is to keep the public surface focused on
192 + end-user commands while still giving developers a one-shot debugging
193 + aid.
194 +- **Implementation**:
195 + - Calls `auth.get_credentials(state.client_oauth_config,
196 + provider=state.auth_provider)` — the exact same code path the
197 + `Client` uses — so the token reflects what the rest of the CLI
198 + would actually send.
199 + - Always calls `creds.refresh(Request())` before reading
200 + `creds.token`. Service-account, GCE/GKE-metadata, and some
201 + impersonated credentials lazy-mint the token; without an explicit
202 + refresh `creds.token` is `None` even for valid credentials.
203 + - Hits `https://oauth2.googleapis.com/tokeninfo?access_token=<token>`
204 + via stdlib `urllib.request` rather than the already-authorized
205 + `requests.AuthorizedSession`. The tokeninfo endpoint accepts the
206 + token as a query parameter and does NOT want a `Bearer` header
207 + alongside it.
208 + - Renders `expires_in` (seconds) as minutes for readability.
209 + - On HTTP 4xx from tokeninfo (typical for revoked/expired tokens),
210 + the JSON error body is surfaced verbatim rather than being
211 + swallowed; the developer needs to see *why* the token was
212 + rejected.
213 +- **Output shape**:
214 + ```
215 + Auth provider: adc
216 + Email: user@example.com
217 + Audience: 764086051850-...apps.googleusercontent.com
218 + Expires in: 47m
219 + Scopes:
220 + - email
221 + - https://www.googleapis.com/auth/cloud-platform
222 + - https://www.googleapis.com/auth/colaboratory
223 + - https://www.googleapis.com/auth/userinfo.email
224 + - openid
225 + ```
226 +
227 +## Implementation Details
228 +
229 +- **Code Injection**: Use a standard `run_code(session, code)` helper via
230 + `ColabRuntime`.
231 +- **History Management**: Use `HistoryLogger` class to append structured
232 + events to session-specific `.jsonl` files.
233 +- **Interactive Prompts**: Instrumented `stdin_hook` and `colab_request_hook`
234 + to record interactive user input and proprietary backend requests.
235 +
236 +## Testing Strategy
237 +
238 +TDD is mandatory for all automation features.
239 +
240 +### 1. Mock Kernel Injection
241 +
242 +- **Test Case**: Verify `colab auth` correctly injects `from google.colab
243 + import auth; auth.authenticate_user()`.
244 +- **Test Case**: Verify `colab install` correctly injects `pip install` or `uv
245 + install` commands to the remote VM kernel.
246 +- **Test Case**: Verify `colab drivemount` correctly injects `drive.mount()`
247 + commands and registers the `colab_request_hook` to intercept credential
248 + propagation events.
249 +
250 +### 2. History Capture
251 +
252 +- **Test Case**: Verify all code sent via `exec` is correctly appended to the
253 + JSON-L history file for that session.
254 +- **Test Case**: Verify `colab log` correctly generates an `.ipynb` from a
255 + populated history file.
256 +
257 +### 3. `whoami` Identity Resolution
258 +
259 +- **Test Case**: Mock the credentials + `urllib.request.urlopen` to return a
260 + fake tokeninfo payload; verify the printed output contains the email, the
261 + active auth provider name, the scopes (one per line), and a human-readable
262 + expires-in (minutes, not raw seconds).
263 +- **Test Case**: When `urlopen` raises `HTTPError(400)` (revoked/expired
264 + token), `whoami` exits non-zero with a message identifying the failure
265 + rather than emitting an unhandled traceback.
266 +- **Test Case**: `colab --help` does NOT mention `whoami` (regression
267 + against accidental un-hiding) but `colab whoami --help` still shows the
268 + command's own help text.
269 +- **Test Case**: `creds.refresh()` is called before `creds.token` is read
270 + (regression against silently-`None` tokens for service-account /
271 + GCE-metadata creds).
docs/3042ab12-2026-05-07.png
Binary files /dev/null and b/docs/3042ab12-2026-05-07.png differ
docs/demos.md new
+625
@@ -0,0 +1,625 @@
1 +# Colab CLI: Demo Walkthroughs
2 +
3 +*Captured 2026-05-07 against a live Colab backend with `showboat` 0.6.1.*
4 +<!-- showboat-id: a24e677e-5052-4bec-8f82-36eb7a7859f9 -->
5 +
6 +Eleven scenarios that exercise common workflows, plus a final "bridging back to the browser" example. Every `colab` invocation below was actually executed; the text inside each `output` block was captured verbatim from stdout/stderr.
7 +
8 +**Methodology**
9 +- Auth: `--auth=adc`. To set up: `gcloud auth application-default login --scopes=https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/colaboratory`.
10 +- Accelerator: every session uses **CPU**. Provisioning real accelerators is gated by per-account quota and would not work for most readers; the workflows themselves are accelerator-agnostic, so where a demo's narrative mentions a GPU or TPU the prose flags the substitution.
11 +- Interactive subcommands — `colab auth`, `colab drivemount`, and unpiped `colab repl` / `colab console` — are **not run** here because they require human interaction at a TTY. Demos that would normally use them include an inline note explaining what they do and the workflow continues with the non-interactive parts.
12 +- `enable_update_check` is set to `false` in `~/.config/colab-cli/settings.json` for the duration of recording so the daily upgrade banner doesn't pollute output.
13 +- `PYTHONWARNINGS=ignore` is set in the environment to suppress the ADC quota-project warning that `google.auth` emits on every call from end-user credentials.
14 +
15 +**Re-verifiability caveat**: this document is **not** re-verifiable with `showboat verify`. Each `colab new` produces a fresh server-assigned session endpoint (`m-s-...`), so the recorded output never matches a re-run exactly. Treat this as a one-time witness that the workflows succeeded as of the recording date.
16 +
17 +## Demo 1: Cloud-native scientist
18 +
19 +Provision a session, run a JAX workload over a small dataset, then tear the session down. Demonstrates the headline pattern of `colab new` → `colab exec` → `colab stop`. (A full-fidelity run of this scenario would also call `colab auth` and `colab drivemount` so the JAX code could read from BigQuery and write to Drive — both interactive, see the skip note below — and would request a TPU instead of CPU.)
20 +
21 +```bash
22 +uv run colab --auth=adc new -s research
23 +```
24 +
25 +```output
26 +[colab] Creating session 'research'...
27 +[colab] Session READY.
28 +```
29 +
30 +*Skipped:* `colab auth -s research` and `colab drivemount -s research`. Both require interactive TTY consent — `auth` prompts the user to visit an OAuth URL and paste back a verification code; `drivemount` prompts for an Enter keypress after the user grants consent in their browser. Verified separately in `integration/`.
31 +
32 +```bash
33 +uv run colab --auth=adc install -s research jax 2>&1 | tail -20
34 +```
35 +
36 +```output
37 +[colab] Installing packages on research (preferring uv)...
38 +Installation Complete (via uv)!
39 +```
40 +
41 +```bash
42 +cat <<'EOF' | uv run colab --auth=adc exec -s research
43 +import jax, jax.numpy as jnp
44 +import numpy as np
45 +
46 +# (BigQuery substituted with synthetic data — would normally use:
47 +# df = bigquery.Client().query('SELECT * FROM bigquery-public-data.ml_datasets.iris LIMIT 100').to_dataframe())
48 +data = np.random.RandomState(0).randn(100, 4)
49 +
50 +print('Devices:', jax.devices())
51 +w = jax.random.normal(jax.random.PRNGKey(0), (4, 4))
52 +out = jax.jit(lambda x, w: x @ w)(jnp.array(data), w)
53 +print(f'Processed {len(out)} rows.')
54 +EOF
55 +
56 +```
57 +
58 +```output
59 +Devices: [CpuDevice(id=0)]
60 +Processed 100 rows.
61 +```
62 +
63 +```bash
64 +uv run colab --auth=adc stop -s research
65 +```
66 +
67 +```output
68 +[colab] Stopping session 'research'...
69 +[colab] Session terminated.
70 +```
71 +
72 +## Demo 2: Fast iteration on GPU
73 +
74 +A typical model-training cycle: provision → install dependencies → run a training script → check status → download the resulting checkpoint. The script here is a 1-layer linear regression on synthetic data so it finishes in a few seconds on CPU; substitute your real training code and `--gpu A100` for a production run.
75 +
76 +```bash
77 +uv run colab --auth=adc new -s trainer
78 +```
79 +
80 +```output
81 +[colab] Creating session 'trainer'...
82 +[colab] Session READY.
83 +```
84 +
85 +```bash
86 +uv run colab --auth=adc install -s trainer torch 2>&1 | tail -5
87 +```
88 +
89 +```output
90 +[colab] Installing packages on trainer (preferring uv)...
91 +Installation Complete (via uv)!
92 +```
93 +
94 +```bash
95 +uv run colab --auth=adc exec -s trainer -f /tmp/train.py
96 +```
97 +
98 +```output
99 +Epoch 1/10: loss 14.380
100 +Epoch 2/10: loss 11.639
101 +Epoch 3/10: loss 9.440
102 +Epoch 4/10: loss 7.671
103 +Epoch 5/10: loss 6.247
104 +Epoch 6/10: loss 5.097
105 +Epoch 7/10: loss 4.167
106 +Epoch 8/10: loss 3.414
107 +Epoch 9/10: loss 2.802
108 +Epoch 10/10: loss 2.305
109 +Training complete.
110 +```
111 +
112 +```bash
113 +uv run colab --auth=adc download -s trainer /content/model.bin /tmp/model.bin && ls -la /tmp/model.bin
114 +```
115 +
116 +```output
117 +[colab] Downloaded '/content/model.bin' to '/tmp/model.bin'
118 +-rw-r----- 1 rtp primarygroup 1877 May 7 23:11 /tmp/model.bin
119 +```
120 +
121 +```bash
122 +uv run colab --auth=adc status -s trainer
123 +```
124 +
125 +```output
126 +[trainer] m-s-kkb-usw1c0-21g32dh850cd4 | Hardware: CPU | Variant: DEFAULT | Status: IDLE
127 + Last Execution: /tmp/train.py at 2026-05-07 23:11:32
128 +```
129 +
130 +```bash
131 +uv run colab --auth=adc stop -s trainer
132 +```
133 +
134 +```output
135 +[colab] Stopping session 'trainer'...
136 +[colab] Session terminated.
137 +```
138 +
139 +## Demo 3: Interactive troubleshooting (piped)
140 +
141 +Both `colab console` and `colab repl` accept piped stdin and exit on EOF, so they compose well with shell pipelines and other CLI tools. This demo investigates remote disk usage with a one-shot shell command, lists `/content`, creates and removes a scratch file, and then queries free space from a one-shot REPL.
142 +
143 +```bash
144 +uv run colab --auth=adc new -s debug
145 +```
146 +
147 +```output
148 +[colab] Creating session 'debug'...
149 +[colab] Session READY.
150 +```
151 +
152 +*Note:* `colab console` connects to a tmux-wrapped pty on the VM, so even when stdin is piped the raw stdout contains terminal-control bytes (cursor moves, status-line repaints, ANSI color). For programmatic consumption, pipe the output through `grep -a` (force binary-safe) and a regex matching the line(s) you care about, as shown below.
153 +
154 +```bash
155 +echo 'df -h /content' | uv run colab --auth=adc console -s debug 2>&1 | grep -aE 'overlay|/dev/'
156 +```
157 +
158 +```output
159 +overlay 108G 21G 87G 20% /
160 +```
161 +
162 +```bash
163 +uv run colab --auth=adc ls -s debug /content
164 +```
165 +
166 +```output
167 +.config/
168 +sample_data/
169 +```
170 +
171 +```bash
172 +echo 'with open("/content/scratch.log", "w") as f: f.write("x" * 1024 * 100)
173 +print("created scratch.log (100 KB)")' | uv run colab --auth=adc exec -s debug
174 +```
175 +
176 +```output
177 +created scratch.log (100 KB)
178 +```
179 +
180 +```bash
181 +uv run colab --auth=adc rm -s debug /content/scratch.log
182 +```
183 +
184 +```output
185 +[colab] Deleted /content/scratch.log
186 +```
187 +
188 +```bash
189 +echo 'import shutil; print(shutil.disk_usage("/").free // 2**30, "GB free")' | uv run colab --auth=adc repl -s debug
190 +```
191 +
192 +```output
193 +86 GB free
194 +```
195 +
196 +```bash
197 +uv run colab --auth=adc stop -s debug
198 +```
199 +
200 +```output
201 +[colab] Stopping session 'debug'...
202 +[colab] Session terminated.
203 +```
204 +
205 +## Demo 4: Multi-modal output (plots & notebooks)
206 +
207 +Demonstrates plot redirection (`--output-image`) and notebook execution (`colab exec -f file.ipynb` writes outputs back into `<name>_output.ipynb`).
208 +
209 +```bash
210 +uv run colab --auth=adc new -s reporter
211 +```
212 +
213 +```output
214 +[colab] Creating session 'reporter'...
215 +[colab] Session READY.
216 +```
217 +
218 +```bash
219 +cat <<'EOF' | uv run colab --auth=adc exec -s reporter --output-image /tmp/sine.png
220 +import matplotlib.pyplot as plt, numpy as np
221 +x = np.linspace(0, 10, 100)
222 +plt.plot(x, np.sin(x)); plt.title('Sine'); plt.show()
223 +EOF
224 +
225 +```
226 +
227 +```output
228 +<Figure size 640x480 with 1 Axes>
229 +
230 +[Image saved to: /tmp/sine.png]
231 +```
232 +
233 +```bash {image}
234 +![Sine wave plot generated on the Colab VM and saved to /tmp/sine.png](/tmp/sine.png)
235 +```
236 +
237 +![Sine wave plot generated on the Colab VM and saved to /tmp/sine.png](3042ab12-2026-05-07.png)
238 +
239 +```bash
240 +uv run colab --auth=adc exec -s reporter -f /tmp/analysis.ipynb && ls /tmp/analysis_output.ipynb
241 +```
242 +
243 +```output
244 +[colab] Parsing notebook '/tmp/analysis.ipynb'...
245 +[colab] Executing cell 1/2 - a8850b8f...
246 +mean = 18
247 +stdev = 13.49
248 +[colab] Executing cell 2/2 - c31a0002...
249 +rows: 6
250 +sum: 108
251 +[colab] Saving notebook with outputs to '/tmp/analysis_output.ipynb'...
252 +/tmp/analysis_output.ipynb
253 +```
254 +
255 +```bash
256 +uv run colab --auth=adc log -s reporter -o /tmp/reporter.md && wc -l /tmp/reporter.md
257 +```
258 +
259 +```output
260 +[colab] Exported history to '/tmp/reporter.md'.
261 +55 /tmp/reporter.md
262 +```
263 +
264 +```bash
265 +uv run colab --auth=adc stop -s reporter
266 +```
267 +
268 +```output
269 +[colab] Stopping session 'reporter'...
270 +[colab] Session terminated.
271 +```
272 +
273 +## Demo 5: Bulk data via GCS
274 +
275 +Pull a batch of objects down from a Google Cloud Storage bucket, transform them on the VM, and pull the results back. A full-fidelity workflow is `colab new --gpu L4` -> `colab auth` (so VM-side `gcloud` works) -> `gcloud storage cp gs://bucket/raw/*.jpg /content/images/` (via piped `colab console`) -> install pillow/torchvision -> process -> download. We skip the auth step here (interactive; the user has to click through OAuth) and substitute synthetic image generation in place of the GCS pull, which keeps the input -> install -> batch-process -> download shape intact.
276 +
277 +```bash
278 +uv run colab --auth=adc new -s data-proc
279 +```
280 +
281 +```output
282 +[colab] Creating session 'data-proc'...
283 +[colab] Session READY.
284 +```
285 +
286 +```bash
287 +uv run colab --auth=adc install -s data-proc pillow 2>&1 | tail -3
288 +```
289 +
290 +```output
291 +[colab] Installing packages on data-proc (preferring uv)...
292 +Installation Complete (via uv)!
293 +```
294 +
295 +```bash
296 +cat <<'EOF' | uv run colab --auth=adc exec -s data-proc
297 +# (would normally pull from GCS via 'gcloud storage cp gs://my-bucket/raw_data/*.jpg')
298 +import os, zipfile
299 +from PIL import Image, ImageFilter
300 +os.makedirs('/content/images', exist_ok=True)
301 +os.makedirs('/content/processed', exist_ok=True)
302 +# Generate 10 synthetic input images
303 +for i in range(10):
304 + Image.new('RGB', (64, 64), (i * 25, 100, 200 - i * 15)).save(f'/content/images/img_{i:02d}.jpg')
305 +# Process: blur each
306 +for src in sorted(os.listdir('/content/images')):
307 + img = Image.open(f'/content/images/{src}').filter(ImageFilter.GaussianBlur(2))
308 + img.save(f'/content/processed/{src}')
309 +# Zip results
310 +with zipfile.ZipFile('/content/processed/batch.zip', 'w') as z:
311 + for f in sorted(os.listdir('/content/processed')):
312 + if f.endswith('.jpg'):
313 + z.write(f'/content/processed/{f}', f)
314 +print(f'Processed {len(os.listdir("/content/processed")) - 1} images, archived to batch.zip')
315 +EOF
316 +
317 +```
318 +
319 +```output
320 +Processed 10 images, archived to batch.zip
321 +```
322 +
323 +```bash
324 +uv run colab --auth=adc download -s data-proc /content/processed/batch.zip /tmp/batch.zip && ls -la /tmp/batch.zip
325 +```
326 +
327 +```output
328 +[colab] Downloaded '/content/processed/batch.zip' to '/tmp/batch.zip'
329 +-rw-r----- 1 rtp primarygroup 7902 May 7 23:19 /tmp/batch.zip
330 +```
331 +
332 +```bash
333 +uv run colab --auth=adc stop -s data-proc
334 +```
335 +
336 +```output
337 +[colab] Stopping session 'data-proc'...
338 +[colab] Session terminated.
339 +```
340 +
341 +## Demo 6: Resource check & subscription
342 +
343 +Inspect a long-running session, then export its history as a notebook for archival. (`colab pay`, which opens `https://colab.research.google.com/signup` in the system browser to manage compute units, would normally fit here too — we don't invoke it because it would pop a browser window in the recording environment.)
344 +
345 +```bash
346 +uv run colab --auth=adc new -s long-running
347 +```
348 +
349 +```output
350 +[colab] Creating session 'long-running'...
351 +[colab] Session READY.
352 +```
353 +
354 +```bash
355 +uv run colab --auth=adc status -s long-running
356 +```
357 +
358 +```output
359 +[long-running] m-s-kkb-use4a2-2qvalahyh7yzg | Hardware: CPU | Variant: DEFAULT | Status: IDLE
360 +```
361 +
362 +```bash
363 +echo 'print("hello from session")' | uv run colab --auth=adc exec -s long-running
364 +```
365 +
366 +```output
367 +hello from session
368 +```
369 +
370 +```bash
371 +uv run colab --auth=adc log -s long-running -o /tmp/checkpoint.ipynb && ls -la /tmp/checkpoint.ipynb
372 +```
373 +
374 +```output
375 +[colab] Exported history to '/tmp/checkpoint.ipynb'.
376 +-rw-r----- 1 rtp primarygroup 974 May 7 23:20 /tmp/checkpoint.ipynb
377 +```
378 +
379 +```bash
380 +uv run colab --auth=adc stop -s long-running
381 +```
382 +
383 +```output
384 +[colab] Stopping session 'long-running'...
385 +[colab] Session terminated.
386 +```
387 +
388 +## Demo 7: Reproducible research
389 +
390 +Quick exploration via piped repl, file inspection via piped exec, then capture the whole session as a notebook artifact via `colab log -o <name>.ipynb`. The notebook is replayable in the Colab UI.
391 +
392 +```bash
393 +uv run colab --auth=adc new -s pivot
394 +```
395 +
396 +```output
397 +[colab] Creating session 'pivot'...
398 +[colab] Session READY.
399 +```
400 +
401 +```bash
402 +uv run colab --auth=adc install -s pivot scipy 2>&1 | tail -3
403 +```
404 +
405 +```output
406 +[colab] Installing packages on pivot (preferring uv)...
407 +Installation Complete (via uv)!
408 +```
409 +
410 +```bash
411 +echo 'from scipy.stats import zscore; print(zscore([1.2, 1.5, 1.1, 10.4, 1.3]))' | uv run colab --auth=adc repl -s pivot
412 +```
413 +
414 +```output
415 +[-0.52020639 -0.43806854 -0.54758568 1.99868773 -0.49282711]
416 +```
417 +
418 +```bash
419 +uv run colab --auth=adc upload -s pivot /tmp/raw_data.csv /content/raw_data.csv
420 +```
421 +
422 +```output
423 +[colab] Uploaded '/tmp/raw_data.csv' to '/content/raw_data.csv'
424 +```
425 +
426 +```bash
427 +echo 'print(open("/content/raw_data.csv").read())' | uv run colab --auth=adc exec -s pivot
428 +```
429 +
430 +```output
431 +id,name,score
432 +1,alice,0.92
433 +2,bob,0.74
434 +3,carol,0.88
435 +4,dave,0.61
436 +5,eve,0.95
437 +
438 +```
439 +
440 +```bash
441 +uv run colab --auth=adc log -s pivot -o /tmp/pivot_discovery.ipynb && ls -la /tmp/pivot_discovery.ipynb
442 +```
443 +
444 +```output
445 +[colab] Exported history to '/tmp/pivot_discovery.ipynb'.
446 +-rw-r----- 1 rtp primarygroup 2854 May 7 23:21 /tmp/pivot_discovery.ipynb
447 +```
448 +
449 +```bash
450 +uv run colab --auth=adc stop -s pivot
451 +```
452 +
453 +```output
454 +[colab] Stopping session 'pivot'...
455 +[colab] Session terminated.
456 +```
457 +
458 +## Demo 8: Local + cloud hybrid
459 +
460 +Run a local script against the remote VM and pull a result back. The full-fidelity version of this demo also calls `colab drivemount` to make Google Drive available at `/content/drive` on the VM (so the script can read shared data); `drivemount` is interactive and skipped here. The kept portion — `colab exec -f local_script.py` running a script that lives on your laptop against a kernel that lives in Colab — is the workflow worth highlighting.
461 +
462 +```bash
463 +uv run colab --auth=adc new -s hybrid
464 +```
465 +
466 +```output
467 +[colab] Creating session 'hybrid'...
468 +[colab] Session READY.
469 +```
470 +
471 +```bash
472 +uv run colab --auth=adc exec -s hybrid -f /tmp/local_analysis.py
473 +```
474 +
475 +```output
476 +Running on: Linux-6.6.113+-x86_64-with-glibc2.35
477 +Hostname: 699413ff1767
478 +Python: 3.12.13
479 +This script lives on my laptop but ran on the Colab VM.
480 +```
481 +
482 +```bash
483 +uv run colab --auth=adc stop -s hybrid
484 +```
485 +
486 +```output
487 +[colab] Stopping session 'hybrid'...
488 +[colab] Session terminated.
489 +```
490 +
491 +## Demo 9: Multi-session orchestration
492 +
493 +Run multiple sessions concurrently, list them, inspect one, stop one. The two sessions here are both CPU; in practice you'd more likely have a mix of accelerator types (e.g. one TPU for training, one GPU for evaluation).
494 +
495 +```bash
496 +uv run colab --auth=adc new -s tpu-cluster && uv run colab --auth=adc new -s gpu-eval
497 +```
498 +
499 +```output
500 +[colab] Creating session 'tpu-cluster'...
501 +[colab] Session READY.
502 +[colab] Creating session 'gpu-eval'...
503 +[colab] Session READY.
504 +```
505 +
506 +```bash
507 +uv run colab --auth=adc sessions
508 +```
509 +
510 +```output
511 +[gpu-eval] m-s-kkb-usc1c0-3cickkby8ivx5 | Hardware: CPU | Variant: DEFAULT
512 +[tpu-cluster] m-s-kkb-use1b1-3b5xes33630p3 | Hardware: CPU | Variant: DEFAULT
513 +```
514 +
515 +```bash
516 +uv run colab --auth=adc status -s gpu-eval
517 +```
518 +
519 +```output
520 +[gpu-eval] m-s-kkb-usc1c0-3cickkby8ivx5 | Hardware: CPU | Variant: DEFAULT | Status: IDLE
521 +```
522 +
523 +```bash
524 +uv run colab --auth=adc stop -s gpu-eval && uv run colab --auth=adc stop -s tpu-cluster
525 +```
526 +
527 +```output
528 +[colab] Stopping session 'gpu-eval'...
529 +[colab] Session terminated.
530 +[colab] Stopping session 'tpu-cluster'...
531 +[colab] Session terminated.
532 +```
533 +
534 +## Demo 10: One-shot pipeline
535 +
536 +Chain several commands with `&&` so any failure aborts. The script here is a tiny stand-in (writes a JSON result to `/content`) so the chain runs in a few seconds on CPU; the typical real version would be `--gpu A100` plus a heavier dependency like `flash-attn`.
537 +
538 +```bash
539 +uv run colab --auth=adc new -s pipeline \
540 + && uv run colab --auth=adc install -s pipeline requests 2>&1 | tail -2 \
541 + && uv run colab --auth=adc exec -s pipeline -f /tmp/local_pipeline.py \
542 + && uv run colab --auth=adc download -s pipeline /content/results.json /tmp/results.json \
543 + && uv run colab --auth=adc stop -s pipeline
544 +```
545 +
546 +```output
547 +[colab] Creating session 'pipeline'...
548 +[colab] Session READY.
549 +[colab] Installing packages on pipeline (preferring uv)...
550 +Installation Complete (via uv)!
551 +Wrote results.json: {'status': 'ok', 'computed_at': '2026-05-07T23:22:34.924350Z', 'value': 42}
552 +/tmp/ipykernel_38852/1782062088.py:5: DeprecationWarning: datetime.datetime.utcnow() is deprecated and scheduled for removal in a future version. Use timezone-aware objects to represent datetimes in UTC: datetime.datetime.now(datetime.UTC).
553 + "computed_at": datetime.datetime.utcnow().isoformat() + "Z",
554 +[colab] Downloaded '/content/results.json' to '/tmp/results.json'
555 +[colab] Stopping session 'pipeline'...
556 +[colab] Session terminated.
557 +```
558 +
559 +## Demo 11: Reproducible environment
560 +
561 +Upload a `requirements.txt` to the VM, install via `-r`, then verify the version on the VM matches what we asked for.
562 +
563 +```bash
564 +uv run colab --auth=adc new -s env-test
565 +```
566 +
567 +```output
568 +[colab] Creating session 'env-test'...
569 +[colab] Session READY.
570 +```
571 +
572 +```bash
573 +uv run colab --auth=adc upload -s env-test /tmp/requirements.txt /content/requirements.txt
574 +```
575 +
576 +```output
577 +[colab] Uploaded '/tmp/requirements.txt' to '/content/requirements.txt'
578 +```
579 +
580 +```bash
581 +uv run colab --auth=adc install -s env-test -r /tmp/requirements.txt 2>&1 | tail -3
582 +```
583 +
584 +```output
585 +[colab] Installing packages on env-test (preferring uv)...
586 +Installation Complete (via uv)!
587 +```
588 +
589 +```bash
590 +echo 'import requests; print("requests:", requests.__version__)' | uv run colab --auth=adc exec -s env-test
591 +```
592 +
593 +```output
594 +requests: 2.31.0
595 +```
596 +
597 +## Bridging back to the browser
598 +
599 +`colab url -s <name>` prints a URL that, when opened in a browser, makes the Colab frontend connect to the existing colab-cli session instead of provisioning a fresh VM. By default it just prints the URL (pipeable, e.g. `colab url -s s1 | xclip`); `--open` would open it directly in the system browser.
600 +
601 +```bash
602 +uv run colab --auth=adc url -s env-test
603 +```
604 +
605 +```output
606 +https://colab.research.google.com/notebooks/empty.ipynb?dbu=%2Ftun%2Fm%2Fm-s-kkb-usc1b1-3tpcjymikv7t3
607 +```
608 +
609 +```bash
610 +uv run colab --auth=adc stop -s env-test
611 +```
612 +
613 +```output
614 +[colab] Stopping session 'env-test'...
615 +[colab] Session terminated.
616 +```
617 +
618 +```bash
619 +uv run colab --auth=adc sessions
620 +```
621 +
622 +```output
623 +[colab] Pruned 1 stale local session(s).
624 +[colab] No active sessions found on server.
625 +```
integration/README.md new
+29
@@ -0,0 +1,29 @@
1 +# Integration tests
2 +
3 +End-to-end tests that run against a **live Colab backend** (unlike the mocked unit tests under `tests/`).
4 +
5 +## Prerequisites
6 +- Google account with Colab access.
7 +- `uv` installed.
8 +- Working auth — verify with `colab sessions`.
9 +
10 +## Scenarios
11 +
12 +| Directory | What it covers |
13 +| --- | --- |
14 +| `repro_plot_redirection/` | `colab exec` of a matplotlib script with `--output-image` redirection. |
15 +| `repro_keep_alive/` | Fast smoke test (~10s): keep-alive daemon spawns, persists its PID, no errors during the pre-flight ping, `colab stop` reaps it. |
16 +| `repro_keep_alive_scope/` | Slow soak test (~95s): runs the daemon long enough for one ping past the pre-flight, asserts no `keep_alive_error` events. |
17 +| `repro_variable_persistence/` | Variables persist across `colab exec` calls in the same session. |
18 +| `repro_piped_console/` | Fast smoke test (~5s including session creation): `echo cmd \| colab console -s s` runs the command and exits within 30s. Regression test for the 2026-05-07 EOF-handler fix. |
19 +
20 +## Running
21 +```bash
22 +uv run bash integration/repro_keep_alive/test.sh
23 +```
24 +`uv run` ensures the local `colab` entry point is on `PATH`.
25 +
26 +## Adding a scenario
27 +1. Create `repro_<short_description>/`.
28 +2. Add a script (`.sh` or `.py`) that demonstrates or verifies the issue.
29 +3. Add a row to the table above noting whether it's fast (smoke) or slow (soak).
integration/repro_keep_alive/test.sh new
+131
@@ -0,0 +1,131 @@
1 +#!/bin/bash
2 +# Copyright 2026 Google LLC
3 +#
4 +# Licensed under the Apache License, Version 2.0 (the "License");
5 +# you may not use this file except in compliance with the License.
6 +# You may obtain a copy of the License at
7 +#
8 +# http://www.apache.org/licenses/LICENSE-2.0
9 +#
10 +# Unless required by applicable law or agreed to in writing, software
11 +# distributed under the License is distributed on an "AS IS" BASIS,
12 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +# See the License for the specific language governing permissions and
14 +# limitations under the License.
15 +#
16 +# Integration Test: Keep-Alive Daemon Lifecycle
17 +# Verifies that `colab new` spawns a detached keep-alive daemon, persists its
18 +# PID in the session state, and that `colab stop` reaps it cleanly.
19 +#
20 +# This test is a fast smoke test (~10s). For a soak test that verifies the
21 +# daemon's pings actually succeed against the live backend, see
22 +# integration/repro_keep_alive_scope/.
23 +
24 +set -e
25 +
26 +# Setup a clean session file for testing
27 +TMP_DIR=$(mktemp -d)
28 +SESSION_FILE="$TMP_DIR/sessions.json"
29 +trap "rm -rf $TMP_DIR" EXIT
30 +
31 +# To exercise the daemon we need OAuth2 or ADC with the `colaboratory`
32 +# scope. Selection priority: OAuth2 (cached token present) > ADC (with the
33 +# right scopes).
34 +EXPECT_DAEMON=1
35 +if [ -f "$HOME/.config/colab-cli/token.json" ]; then
36 + AUTH_FLAGS="--auth=oauth2"
37 +elif command -v gcloud > /dev/null && gcloud auth application-default print-access-token > /dev/null 2>&1; then
38 + # Check that ADC has both required scopes (userinfo.email + colaboratory).
39 + ADC_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
40 + ADC_SCOPES=$(curl -s "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=$ADC_TOKEN" | python3 -c "import json,sys; print(json.load(sys.stdin).get('scope',''))" 2>/dev/null)
41 + if echo "$ADC_SCOPES" | grep -q "colaboratory" && echo "$ADC_SCOPES" | grep -q "userinfo.email"; then
42 + AUTH_FLAGS="--auth=adc"
43 + else
44 + echo "Error: ADC token lacks the required scopes."
45 + echo "Re-issue ADC creds with both required scopes:"
46 + echo " gcloud auth application-default login \\"
47 + echo " --scopes=https://www.googleapis.com/auth/userinfo.email,\\"
48 + echo " https://www.googleapis.com/auth/colaboratory"
49 + exit 1
50 + fi
51 +else
52 + echo "Error: No usable auth provider found."
53 + echo "Options:"
54 + echo " - OAuth2: run 'uv run colab --auth=oauth2 sessions' to bootstrap"
55 + echo " - ADC: gcloud auth application-default login \\"
56 + echo " --scopes=https://www.googleapis.com/auth/userinfo.email,\\"
57 + echo " https://www.googleapis.com/auth/colaboratory"
58 + exit 1
59 +fi
60 +
61 +SESSION_NAME="test-live-keep-alive"
62 +
63 +cleanup_session() {
64 + uv run colab $AUTH_FLAGS --config "$SESSION_FILE" stop -s "$SESSION_NAME" 2>/dev/null || true
65 +}
66 +trap "cleanup_session; rm -rf $TMP_DIR" EXIT
67 +
68 +echo "[*] Creating new session (REAL API CALL) using $AUTH_FLAGS..."
69 +uv run colab $AUTH_FLAGS --config "$SESSION_FILE" new -s "$SESSION_NAME"
70 +
71 +# Verify session exists in state
72 +if [ ! -f "$SESSION_FILE" ]; then
73 + echo "Error: Session file '$SESSION_FILE' not created."
74 + exit 1
75 +fi
76 +
77 +grep "$SESSION_NAME" "$SESSION_FILE"
78 +
79 +# Extract PID (may be null if keep-alive was intentionally disabled).
80 +PID=$(grep -A 15 "$SESSION_NAME" "$SESSION_FILE" | grep "keep_alive_pid" | awk '{print $2}' | tr -d ',')
81 +
82 +if [ -z "$PID" ] || [ "$PID" == "null" ]; then
83 + echo "[FAILURE] No keep_alive_pid found under $AUTH_FLAGS (daemon should have spawned)."
84 + cat "$SESSION_FILE"
85 + exit 1
86 +fi
87 +echo "[*] Keep-alive PID: $PID"
88 +
89 +if ps -p $PID > /dev/null; then
90 + echo "[*] Keep-alive process is running."
91 +else
92 + echo "[FAILURE] Keep-alive process NOT running."
93 + exit 1
94 +fi
95 +
96 +# Verify the process command line is actually colab keep-alive
97 +ps -fp $PID | grep "keep-alive"
98 +
99 +LOG_OUTPUT=$(uv run colab $AUTH_FLAGS --config "$SESSION_FILE" log -s "$SESSION_NAME")
100 +echo "$LOG_OUTPUT"
101 +if ! echo "$LOG_OUTPUT" | grep -q "KEEP: started"; then
102 + echo "[FAILURE] keep_alive_started event missing from history."
103 + exit 1
104 +fi
105 +# The pre-flight in `colab new` calls keep_alive_assignment once
106 +# synchronously before returning. If that succeeded, the structured
107 +# history should NOT contain any KEEP: error events at this point.
108 +if echo "$LOG_OUTPUT" | grep -q "KEEP: error"; then
109 + echo "[FAILURE] keep_alive_error events present immediately after 'colab new'."
110 + echo " The pre-flight keep-alive ping failed. Check the body= field."
111 + exit 1
112 +fi
113 +
114 +echo "[*] Stopping session (REAL API CALL)..."
115 +uv run colab $AUTH_FLAGS --config "$SESSION_FILE" stop -s "$SESSION_NAME"
116 +sleep 1
117 +
118 +if [ "$EXPECT_DAEMON" -eq 1 ]; then
119 + if ! ps -p $PID > /dev/null; then
120 + echo "[*] Keep-alive process terminated successfully."
121 + else
122 + echo "[FAILURE] Keep-alive process still running after stop!"
123 + kill $PID
124 + exit 1
125 + fi
126 +fi
127 +
128 +# Disable the cleanup trap; we already cleaned up.
129 +trap "rm -rf $TMP_DIR" EXIT
130 +
131 +echo "[SUCCESS] Live integration test passed!"
integration/repro_keep_alive_scope/test.sh new
+152
@@ -0,0 +1,152 @@
1 +#!/bin/bash
2 +# Copyright 2026 Google LLC
3 +#
4 +# Licensed under the Apache License, Version 2.0 (the "License");
5 +# you may not use this file except in compliance with the License.
6 +# You may obtain a copy of the License at
7 +#
8 +# http://www.apache.org/licenses/LICENSE-2.0
9 +#
10 +# Unless required by applicable law or agreed to in writing, software
11 +# distributed under the License is distributed on an "AS IS" BASIS,
12 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +# See the License for the specific language governing permissions and
14 +# limitations under the License.
15 +#
16 +# Integration Test: Keep-Alive Daemon Soak (OAuth Scope Regression Guard)
17 +#
18 +# Background:
19 +# On 2026-04-30, a regression was discovered where `colab new` would succeed
20 +# but the keep-alive daemon would silently die ~1 minute later, causing the
21 +# VM to be idle-pruned shortly after. Two underlying causes:
22 +# (a) The RuntimeService at colab.pa.googleapis.com requires
23 +# `X-Goog-Api-Client` to contain `grpc-web`. Missing this returns 400.
24 +# (b) The same service requires the
25 +# `https://www.googleapis.com/auth/colaboratory` OAuth scope.
26 +# Missing this returns 403 SCOPE_NOT_PERMITTED.
27 +# Both unit-test layers passed because they mock the network. The bug only
28 +# surfaces against the live backend.
29 +#
30 +# What this test does:
31 +# 1. Spawns a real Colab session via `colab new`.
32 +# 2. Waits 90 seconds — long enough for the daemon to hit at least one
33 +# ping iteration *after* the pre-flight (the loop sleeps 60s between
34 +# pings).
35 +# 3. Reads the structured history via `colab log` and asserts NO
36 +# `KEEP: error` events were recorded. Any error event means a daemon
37 +# ping was rejected by the server, which is the regression.
38 +# 4. Verifies the daemon process is still alive.
39 +# 5. Cleans up via `colab stop`.
40 +#
41 +# Cost: ~95 seconds of real wall-clock time + one short-lived Colab CPU
42 +# assignment.
43 +
44 +set -e
45 +
46 +# Use a uniquely-named session per run so we don't trip on stale history
47 +# from previous runs (history files are keyed by session name and live at
48 +# ~/.config/colab-cli/history/<name>.jsonl).
49 +SESSION_NAME="repro-keep-alive-scope-$(date +%s)"
50 +
51 +TMP_DIR=$(mktemp -d)
52 +SESSION_FILE="$TMP_DIR/sessions.json"
53 +
54 +# This test soaks the keep-alive daemon for 90s, so it is meaningful only on
55 +# auth providers that actually spawn a daemon. We require either
56 +# OAuth2 or properly-scoped ADC.
57 +if [ -f "$HOME/.config/colab-cli/token.json" ]; then
58 + AUTH_FLAGS="--auth=oauth2"
59 +elif command -v gcloud > /dev/null && gcloud auth application-default print-access-token > /dev/null 2>&1; then
60 + ADC_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
61 + ADC_SCOPES=$(curl -s "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=$ADC_TOKEN" | python3 -c "import json,sys; print(json.load(sys.stdin).get('scope',''))" 2>/dev/null)
62 + if echo "$ADC_SCOPES" | grep -q "colaboratory" && echo "$ADC_SCOPES" | grep -q "userinfo.email"; then
63 + AUTH_FLAGS="--auth=adc"
64 + else
65 + echo "Skipping: ADC token lacks required scopes (need both"
66 + echo " userinfo.email and colaboratory). Re-issue with:"
67 + echo " gcloud auth application-default login \\"
68 + echo " --scopes=https://www.googleapis.com/auth/userinfo.email,\\"
69 + echo " https://www.googleapis.com/auth/colaboratory"
70 + exit 0 # environment-not-applicable
71 + fi
72 +else
73 + echo "Skipping: this test requires --auth=oauth2 or properly-scoped ADC"
74 + echo " Bootstrap options:"
75 + echo " - OAuth2: 'uv run colab --auth=oauth2 sessions' (browser consent)"
76 + echo " - ADC: gcloud auth application-default login \\"
77 + echo " --scopes=https://www.googleapis.com/auth/userinfo.email,\\"
78 + echo " https://www.googleapis.com/auth/colaboratory"
79 + exit 0 # environment-not-applicable
80 +fi
81 +
82 +cleanup() {
83 + echo "[*] Cleaning up..."
84 + uv run colab $AUTH_FLAGS --config "$SESSION_FILE" stop -s "$SESSION_NAME" 2>/dev/null || true
85 + rm -rf "$TMP_DIR"
86 + # Best-effort: scrub the history file so the test is idempotent.
87 + rm -f "$HOME/.config/colab-cli/history/${SESSION_NAME}.jsonl"
88 +}
89 +trap cleanup EXIT
90 +
91 +echo "[*] Creating session '$SESSION_NAME' (REAL API CALL) using $AUTH_FLAGS..."
92 +# Note: `colab new` now performs a synchronous keep-alive pre-flight. If the
93 +# OAuth scope is missing, this command itself will fail fast with an
94 +# actionable remediation message — so step (1) of the regression already
95 +# fires here.
96 +if ! uv run colab $AUTH_FLAGS --config "$SESSION_FILE" new -s "$SESSION_NAME"; then
97 + echo "[FAILURE] 'colab new' failed. If this is a SCOPE_NOT_PERMITTED error,"
98 + echo " the colaboratory scope is missing from your auth provider."
99 + echo " For ADC: gcloud auth application-default login \\"
100 + echo " --scopes=https://www.googleapis.com/auth/cloud-platform,\\"
101 + echo " https://www.googleapis.com/auth/colaboratory"
102 + exit 1
103 +fi
104 +
105 +# Sanity-check the session was persisted with a daemon PID.
106 +PID=$(grep -A 15 "$SESSION_NAME" "$SESSION_FILE" | grep "keep_alive_pid" | awk '{print $2}' | tr -d ',')
107 +if [ -z "$PID" ] || [ "$PID" == "null" ]; then
108 + echo "[FAILURE] No keep_alive_pid recorded for session."
109 + cat "$SESSION_FILE"
110 + exit 1
111 +fi
112 +echo "[*] Keep-alive daemon PID: $PID"
113 +
114 +# Soak: wait long enough for at least one daemon-driven ping (loop sleeps
115 +# 60s) to land *after* the pre-flight that `colab new` did. 90s gives us a
116 +# comfortable margin.
117 +echo "[*] Soaking for 90s to let the daemon perform at least one ping..."
118 +sleep 90
119 +
120 +# The daemon must still be alive.
121 +if ! ps -p $PID > /dev/null; then
122 + echo "[FAILURE] Keep-alive daemon (pid=$PID) died during soak."
123 + echo " History dump:"
124 + uv run colab $AUTH_FLAGS --config "$SESSION_FILE" log -s "$SESSION_NAME" || true
125 + exit 1
126 +fi
127 +echo "[*] Daemon still alive after 90s."
128 +
129 +# The structured history must NOT contain any keep_alive_error events. Any
130 +# error here means a server-side rejection (auth, headers, payload) — the
131 +# exact class of bug this test guards against.
132 +LOG_OUTPUT=$(uv run colab $AUTH_FLAGS --config "$SESSION_FILE" log -s "$SESSION_NAME")
133 +echo "----- colab log output -----"
134 +echo "$LOG_OUTPUT"
135 +echo "----------------------------"
136 +
137 +if echo "$LOG_OUTPUT" | grep -q "KEEP: error"; then
138 + echo "[FAILURE] keep_alive_error events recorded during soak."
139 + echo " This indicates the daemon's pings are being rejected."
140 + echo " Common causes:"
141 + echo " - Missing 'colaboratory' OAuth scope (403 SCOPE_NOT_PERMITTED)"
142 + echo " - Missing X-Goog-Api-Client: grpc-web header (400 Invalid GRPC-Web)"
143 + exit 1
144 +fi
145 +
146 +# Positive assertion: we expect at least one KEEP: started event.
147 +if ! echo "$LOG_OUTPUT" | grep -q "KEEP: started"; then
148 + echo "[FAILURE] No KEEP: started event recorded — daemon never ran?"
149 + exit 1
150 +fi
151 +
152 +echo "[SUCCESS] Keep-alive daemon survived 90s soak with zero error events."
integration/repro_piped_console/test.sh new
+92
@@ -0,0 +1,92 @@
1 +#!/bin/bash
2 +# Copyright 2026 Google LLC
3 +#
4 +# Licensed under the Apache License, Version 2.0 (the "License");
5 +# you may not use this file except in compliance with the License.
6 +# You may obtain a copy of the License at
7 +#
8 +# http://www.apache.org/licenses/LICENSE-2.0
9 +#
10 +# Unless required by applicable law or agreed to in writing, software
11 +# distributed under the License is distributed on an "AS IS" BASIS,
12 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +# See the License for the specific language governing permissions and
14 +# limitations under the License.
15 +#
16 +# Integration Test: Piped console exits cleanly on EOF
17 +# Verifies that `echo cmd | colab console -s s` runs the command on the remote
18 +# /colab/tty endpoint and then exits within a few seconds.
19 +#
20 +# Regression: prior to 2026-05-07 this hung indefinitely because the EOF
21 +# handler sent \x04 (Ctrl-D), which the remote tmux-wrapped bash treated as a
22 +# literal character rather than a session terminator. The fix sends "exit\n"
23 +# and closes the websocket from the client side after a short grace period.
24 +
25 +set -e
26 +
27 +TMP_DIR=$(mktemp -d)
28 +SESSION_FILE="$TMP_DIR/sessions.json"
29 +SESSION_NAME="test-piped-console"
30 +
31 +# Auth selection (same priority order as repro_keep_alive).
32 +if [ -f "$HOME/.config/colab-cli/token.json" ]; then
33 + AUTH_FLAGS="--auth=oauth2"
34 +elif command -v gcloud > /dev/null && gcloud auth application-default print-access-token > /dev/null 2>&1; then
35 + ADC_TOKEN=$(gcloud auth application-default print-access-token 2>/dev/null)
36 + ADC_SCOPES=$(curl -s "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=$ADC_TOKEN" | python3 -c "import json,sys; print(json.load(sys.stdin).get('scope',''))" 2>/dev/null)
37 + if echo "$ADC_SCOPES" | grep -q "userinfo.email"; then
38 + AUTH_FLAGS="--auth=adc"
39 + else
40 + echo "Error: ADC token lacks the userinfo.email scope."
41 + exit 1
42 + fi
43 +else
44 + echo "Error: No usable auth provider found." >&2
45 + exit 1
46 +fi
47 +
48 +cleanup_session() {
49 + uv run colab $AUTH_FLAGS --config "$SESSION_FILE" stop -s "$SESSION_NAME" 2>/dev/null || true
50 +}
51 +trap "cleanup_session; rm -rf $TMP_DIR" EXIT
52 +
53 +echo "[*] Creating session for piped-console test using $AUTH_FLAGS..."
54 +uv run colab $AUTH_FLAGS --config "$SESSION_FILE" new -s "$SESSION_NAME"
55 +
56 +# Marker string we'll grep for in the captured output. Made unique enough that
57 +# accidental matches in tmux status lines or shell prompts are impossible.
58 +MARKER="PIPED-CONSOLE-OK-$(date +%s)-$$"
59 +
60 +OUTPUT_FILE="$TMP_DIR/console-output.txt"
61 +
62 +echo "[*] Running: echo 'echo $MARKER' | colab console (must exit within 30s)..."
63 +START=$(date +%s)
64 +# 30s upper bound guards against the prior hang regression. The fix typically
65 +# completes in ~1-2s; anything beyond that means the EOF/close path broke.
66 +if ! timeout 30 bash -c "echo 'echo $MARKER' | uv run colab $AUTH_FLAGS --config '$SESSION_FILE' console -s '$SESSION_NAME'" > "$OUTPUT_FILE" 2>&1; then
67 + EXIT=$?
68 + if [ $EXIT -eq 124 ]; then
69 + echo "[FAILURE] Piped console hung past the 30s timeout (regression — was the EOF handler reverted?)."
70 + else
71 + echo "[FAILURE] Piped console exited non-zero: $EXIT"
72 + fi
73 + cat "$OUTPUT_FILE"
74 + exit 1
75 +fi
76 +ELAPSED=$(($(date +%s) - START))
77 +echo "[*] Piped console returned in ${ELAPSED}s."
78 +
79 +if ! grep -q "$MARKER" "$OUTPUT_FILE"; then
80 + echo "[FAILURE] Marker '$MARKER' not found in console output (the command did not actually execute on the VM)."
81 + cat "$OUTPUT_FILE"
82 + exit 1
83 +fi
84 +echo "[*] Confirmed: the piped command actually ran on the remote VM."
85 +
86 +# Sanity: the doc claims ~1.2s round-trip. Anything > 10s is suspicious even
87 +# if it eventually exited (probably means the grace window was bumped wildly).
88 +if [ "$ELAPSED" -gt 10 ]; then
89 + echo "[WARNING] Piped console took ${ELAPSED}s — slow, but not a hang."
90 +fi
91 +
92 +echo "[SUCCESS] Piped console integration test passed (elapsed=${ELAPSED}s)."
integration/repro_plot_redirection/test.sh new
+60
@@ -0,0 +1,60 @@
1 +#!/bin/bash
2 +# Copyright 2026 Google LLC
3 +#
4 +# Licensed under the Apache License, Version 2.0 (the "License");
5 +# you may not use this file except in compliance with the License.
6 +# You may obtain a copy of the License at
7 +#
8 +# http://www.apache.org/licenses/LICENSE-2.0
9 +#
10 +# Unless required by applicable law or agreed to in writing, software
11 +# distributed under the License is distributed on an "AS IS" BASIS,
12 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +# See the License for the specific language governing permissions and
14 +# limitations under the License.
15 +
16 +set -e
17 +
18 +# Integration Test: Plot Redirection
19 +# Verifies that 'colab exec --output-image' correctly intercepts and saves plots.
20 +
21 +SESSION_NAME="repro-plot-$(date +%s)"
22 +OUTPUT_FILE="intercepted_plot.png"
23 +SCRIPT_FILE="plot_gen.py"
24 +
25 +# Cleanup on exit
26 +cleanup() {
27 + echo "[*] Cleaning up..."
28 + colab stop -s "$SESSION_NAME" || true
29 + rm -f "$SCRIPT_FILE" "$OUTPUT_FILE"
30 +}
31 +trap cleanup EXIT
32 +
33 +echo "[*] Creating script..."
34 +cat <<EOF > "$SCRIPT_FILE"
35 +import matplotlib.pyplot as plt
36 +import numpy as np
37 +
38 +x = np.linspace(0, 10, 100)
39 +y = np.sin(x)
40 +
41 +plt.figure(figsize=(8, 4))
42 +plt.plot(x, y)
43 +plt.title("Repro Plot")
44 +plt.show()
45 +EOF
46 +
47 +echo "[*] Starting session..."
48 +colab new -s "$SESSION_NAME"
49 +
50 +echo "[*] Running execution with plot redirection..."
51 +# Note: On a fresh VM, this may trigger the retry logic.
52 +colab exec -s "$SESSION_NAME" -f "$SCRIPT_FILE" --output-image "$OUTPUT_FILE"
53 +
54 +if [ -f "$OUTPUT_FILE" ]; then
55 + echo "[SUCCESS] Plot intercepted and saved to $OUTPUT_FILE"
56 + ls -l "$OUTPUT_FILE"
57 +else
58 + echo "[FAILURE] Plot file not found!"
59 + exit 1
60 +fi
integration/repro_variable_persistence/test.sh new
+51
@@ -0,0 +1,51 @@
1 +#!/bin/bash
2 +# Copyright 2026 Google LLC
3 +#
4 +# Licensed under the Apache License, Version 2.0 (the "License");
5 +# you may not use this file except in compliance with the License.
6 +# You may obtain a copy of the License at
7 +#
8 +# http://www.apache.org/licenses/LICENSE-2.0
9 +#
10 +# Unless required by applicable law or agreed to in writing, software
11 +# distributed under the License is distributed on an "AS IS" BASIS,
12 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +# See the License for the specific language governing permissions and
14 +# limitations under the License.
15 +
16 +# set -e # Don't exit on error so we can capture the failure
17 +
18 +# Integration Test: Variable Persistence
19 +# Verifies if variables defined in one 'colab exec' call persist in the next.
20 +
21 +SESSION_NAME="repro-persist-$(date +%s)"
22 +
23 +# Cleanup on exit
24 +cleanup() {
25 + echo "[*] Cleaning up..."
26 + colab stop -s "$SESSION_NAME" || true
27 +}
28 +trap cleanup EXIT
29 +
30 +echo "[*] Starting session..."
31 +colab new -s "$SESSION_NAME"
32 +
33 +echo "[*] Phase 1: Defining variable 'eric'..."
34 +echo 'eric = "present"' | colab exec -s "$SESSION_NAME"
35 +
36 +echo "[*] Phase 2: Attempting to access 'eric'..."
37 +# If this fails, it will return exit code 0 but print a Traceback to stderr
38 +OUTPUT=$(echo 'print(f"Value of eric: {eric}")' | colab exec -s "$SESSION_NAME" 2>&1)
39 +
40 +echo "[*] Result:"
41 +echo "$OUTPUT"
42 +
43 +if echo "$OUTPUT" | grep -q "NameError: name 'eric' is not defined"; then
44 + echo "[FAILURE] Variable persistence failed (NameError detected)."
45 + exit 1
46 +elif echo "$OUTPUT" | grep -q "Value of eric: present"; then
47 + echo "[SUCCESS] Variable persistence verified."
48 +else
49 + echo "[UNKNOWN] Unexpected output format."
50 + exit 1
51 +fi
pyproject.toml new
+49
@@ -0,0 +1,49 @@
1 +[project]
2 +name = "colab"
3 +dynamic = ["version"]
4 +description = "CLI for interacting with Colab."
5 +readme = "README.md"
6 +requires-python = ">=3.13"
7 +dependencies = [
8 + "google-auth>=2.49.1",
9 + "google-auth-oauthlib>=1.3.0",
10 + "jupyter-kernel-client",
11 + "nbformat>=5.10.4",
12 + "packaging>=24.0",
13 + "prompt-toolkit>=3.0.52",
14 + "pydantic>=2.12.5",
15 + "pygments>=2.19.2",
16 + "pytest>=9.0.2",
17 + "pytest-cov>=7.0.0",
18 + "pytest-mock>=3.15.1",
19 + "requests>=2.32.5",
20 + "rich>=14.3.3",
21 + "typer>=0.24.1",
22 +]
23 +
24 +[project.scripts]
25 +colab = "colab_cli.cli:main"
26 +
27 +[build-system]
28 +requires = ["hatchling", "hatch-vcs"]
29 +build-backend = "hatchling.build"
30 +
31 +[tool.hatch.version]
32 +source = "vcs"
33 +
34 +[tool.hatch.build.targets.wheel]
35 +packages = ["src/colab_cli"]
36 +
37 +[tool.uv]
38 +package = true
39 +
40 +[[tool.uv.index]]
41 +url = "https://pypi.org/simple"
42 +
43 +[tool.uv.sources]
44 +jupyter-kernel-client = { git = "https://github.com/googlecolab/jupyter-kernel-client.git" }
45 +
46 +[dependency-groups]
47 +dev = [
48 + "ruff>=0.15.6",
49 +]
src/colab_cli/auth.py new
+148
@@ -0,0 +1,148 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import enum
16 +import json
17 +import logging
18 +import os
19 +from typing import Optional
20 +
21 +import google.auth
22 +from google.auth.transport import requests
23 +from google.auth.transport.requests import Request
24 +from google.oauth2.credentials import Credentials
25 +from google_auth_oauthlib.flow import InstalledAppFlow
26 +
27 +logger = logging.getLogger(__name__)
28 +
29 +
30 +class AuthProvider(str, enum.Enum):
31 + """Authentication strategy for talking to the Colab backend.
32 +
33 + Values are the lowercase strings accepted by the global ``--auth`` flag.
34 + """
35 +
36 + OAUTH2 = "oauth2"
37 + ADC = "adc"
38 +
39 +
40 +# Standard Scopes for Colab and Drive (Public Auth)
41 +PUBLIC_SCOPES = [
42 + "openid",
43 + "https://www.googleapis.com/auth/userinfo.profile",
44 + "https://www.googleapis.com/auth/userinfo.email",
45 + "https://www.googleapis.com/auth/colaboratory",
46 + "https://www.googleapis.com/auth/drive.file",
47 +]
48 +
49 +
50 +TOKEN_CONFIG_PATH = os.path.expanduser("~/.config/colab-cli/token.json")
51 +OAUTH_SERVER_PORT = 8200
52 +
53 +
54 +def _get_google_auth_credentials(config_path: str) -> Credentials:
55 + """
56 + Retrieves credentials using standard public OAuth2 flow.
57 + """
58 + client_config = None
59 + if os.path.exists(config_path):
60 + with open(config_path, "r") as f:
61 + client_config = json.load(f)
62 + if not client_config:
63 + raise FileNotFoundError(
64 + f"Client OAuth config not found at {config_path}. "
65 + "Please provide a valid path via -c/--client-oauth-config."
66 + )
67 +
68 + creds = None
69 +
70 + # Ensure config directory exists for the token file
71 + os.makedirs(os.path.dirname(TOKEN_CONFIG_PATH), exist_ok=True)
72 +
73 + if os.path.exists(TOKEN_CONFIG_PATH):
74 + try:
75 + creds = Credentials.from_authorized_user_file(
76 + TOKEN_CONFIG_PATH, PUBLIC_SCOPES
77 + )
78 + except Exception as e:
79 + logger.warning(f"Failed to load token from {TOKEN_CONFIG_PATH}: {e}")
80 +
81 + if not creds or not creds.valid:
82 + if creds and creds.expired and creds.refresh_token:
83 + try:
84 + creds.refresh(Request())
85 + except Exception as e:
86 + logger.warning(f"Failed to refresh token: {e}")
87 + creds = None
88 +
89 + if not creds:
90 + flow = InstalledAppFlow.from_client_config(client_config, PUBLIC_SCOPES)
91 + creds = flow.run_local_server(port=OAUTH_SERVER_PORT)
92 +
93 + # Save the credentials for the next run
94 + try:
95 + with open(TOKEN_CONFIG_PATH, "w") as token_file:
96 + token_file.write(creds.to_json())
97 + except Exception as e:
98 + logger.error(f"Failed to save token to {TOKEN_CONFIG_PATH}: {e}")
99 +
100 + return creds
101 +
102 +
103 +def _get_adc_credentials() -> Credentials:
104 + """Retrieves credentials using Google Application Default Credentials.
105 +
106 + Honors the standard ADC discovery chain (``GOOGLE_APPLICATION_CREDENTIALS``,
107 + ``gcloud auth application-default login``, GCE/GKE metadata server, etc.).
108 +
109 + The RuntimeService at colab.pa.googleapis.com requires the
110 + `colaboratory` scope (otherwise keep-alive returns 403 SCOPE_NOT_PERMITTED).
111 + Most ADC credential types (service accounts, GCE/GKE, impersonated)
112 + support `with_scopes`; user credentials minted by
113 + `gcloud auth application-default login` do not. For the latter, the user
114 + must re-run `gcloud auth application-default login` with
115 + `--scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/colaboratory`.
116 + """
117 + creds, _ = google.auth.default(scopes=list(PUBLIC_SCOPES))
118 + # Some credential subclasses ignore the `scopes=` kwarg in `default()`
119 + # (e.g. user creds), so re-apply via `with_scopes` when supported.
120 + if getattr(creds, "requires_scopes", False):
121 + try:
122 + creds = creds.with_scopes(list(PUBLIC_SCOPES))
123 + except Exception as e: # NotImplementedError for non-scopable creds.
124 + logger.debug(f"Could not augment ADC scopes via with_scopes: {e}")
125 + return creds
126 +
127 +
128 +def get_credentials(
129 + config_path: Optional[str] = None,
130 + provider: AuthProvider = AuthProvider.OAUTH2,
131 +) -> requests.AuthorizedSession:
132 + """Unified entry point for retrieving an authorized session.
133 +
134 + Args:
135 + config_path: Path to the OAuth2 client config JSON. Only consulted when
136 + ``provider`` is ``OAUTH2``.
137 + provider: Which authentication strategy to use.
138 + """
139 + if provider == AuthProvider.OAUTH2:
140 + if not config_path:
141 + config_path = os.path.expanduser("~/.colab-cli-oauth-config.json")
142 + creds = _get_google_auth_credentials(config_path)
143 + elif provider == AuthProvider.ADC:
144 + creds = _get_adc_credentials()
145 + else:
146 + raise ValueError(f"Unknown auth provider: {provider!r}")
147 +
148 + return requests.AuthorizedSession(creds)
src/colab_cli/auto_update.py new
+226
@@ -0,0 +1,226 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Auto-update subsystem.
16 +
17 +Owns version detection, the PyPI-style update probe, the on-disk
18 +``latest_version`` cache, and the upgrade-banner UX. The CLI's global
19 +callback (``cli.py``) calls ``check_for_updates`` once per day and
20 +``maybe_show_cached_banner`` on every other invocation; the
21 +``colab update`` Typer command (``commands/utility.py``) delegates to
22 +``check_for_updates``.
23 +"""
24 +
25 +import json
26 +import subprocess
27 +import urllib.request
28 +from datetime import datetime, timezone
29 +from importlib.metadata import PackageNotFoundError, version as installed_version
30 +from packaging.version import InvalidVersion, Version
31 +from typing import Optional
32 +
33 +import typer
34 +
35 +from colab_cli.common import state
36 +from colab_cli.state import Settings
37 +
38 +
39 +# ---------- Version detection -------------------------------------------
40 +
41 +
42 +def get_app_version() -> str:
43 + """Return the installed package version, falling back to the git short hash."""
44 + try:
45 + return installed_version("colab")
46 + except (PackageNotFoundError, InvalidVersion):
47 + pass
48 +
49 + try:
50 + return subprocess.check_output(
51 + ["git", "rev-parse", "--short", "HEAD"],
52 + stderr=subprocess.DEVNULL,
53 + encoding="utf-8",
54 + ).strip()
55 + except Exception:
56 + return "unknown"
57 +
58 +
59 +# ---------- Source fetchers ---------------------------------------------
60 +
61 +
62 +def _parse_version(payload: Optional[dict]) -> Optional[str]:
63 + """Returns ``info.version`` from a PyPI-style payload, or None."""
64 + return (payload or {}).get("info", {}).get("version")
65 +
66 +
67 +def _fetch_pypi(url: str, quiet: bool) -> Optional[dict]:
68 + """Fetches and parses the PyPI-style JSON document at ``url``."""
69 + try:
70 + with urllib.request.urlopen(url, timeout=5) as response:
71 + return json.loads(response.read().decode("utf-8"))
72 + except Exception as e:
73 + if not quiet:
74 + typer.echo(f"[colab] Warning: Failed to fetch update info: {e}")
75 + return None
76 +
77 +
78 +# ---------- Version comparison ------------------------------------------
79 +
80 +
81 +def _is_newer(candidate: Optional[str], current: str) -> bool:
82 + """True when ``candidate`` strictly exceeds ``current`` (PEP 440)."""
83 + if not candidate:
84 + return False
85 + try:
86 + return Version(candidate) > Version(current)
87 + except InvalidVersion:
88 + return candidate != current
89 +
90 +
91 +# ---------- UX ----------------------------------------------------------
92 +
93 +
94 +def announce_upgrade(
95 + latest: str,
96 + current: str,
97 + install_cmd: str,
98 + *,
99 + show_disable_hint: bool = False,
100 +) -> None:
101 + """Print the upgrade banner.
102 +
103 + ``show_disable_hint`` controls whether the trailing line that explains
104 + how to silence the auto-check is included. It is only added when the
105 + banner is shown unsolicited (the daily background fetch and the cached
106 + banner on subsequent invocations); explicit ``colab update`` calls
107 + omit it because the user already opted in to seeing the result.
108 + """
109 + typer.echo(
110 + f"\n[colab] A new version of Colab CLI is available: {latest} (current: {current})"
111 + )
112 + typer.echo(f"[colab] Run '{install_cmd}' to update.")
113 + if show_disable_hint:
114 + typer.echo(
115 + "[colab] To silence this check, set "
116 + '"enable_update_check": false in '
117 + "~/.config/colab-cli/settings.json"
118 + )
119 + typer.echo("")
120 +
121 +
122 +# ---------- Orchestration -----------------------------------------------
123 +
124 +
125 +def check_for_updates(quiet: bool = False) -> None:
126 + """Check PyPI for updates and print a message if a new version is available.
127 +
128 + The disable-hint is appended to the banner only when ``quiet`` is True
129 + (the daily background fetch); explicit ``colab update`` invocations
130 + (``quiet=False``) omit it because the user asked for the check.
131 + """
132 + settings = state.settings_store.load()
133 + current = get_app_version()
134 +
135 + try:
136 + pypi = _fetch_pypi(settings.update_url, quiet)
137 + pypi_v = _parse_version(pypi)
138 +
139 + if _is_newer(pypi_v, current):
140 + announce_upgrade(
141 + pypi_v,
142 + current,
143 + "pip install --upgrade colab",
144 + show_disable_hint=quiet,
145 + )
146 + elif not quiet:
147 + suffix = f", latest: {pypi_v}" if pypi_v else ""
148 + typer.echo(f"[colab] Colab CLI is up to date (version: {current}{suffix}).")
149 +
150 + # Cache the highest observed version; never downgrade.
151 + cached = settings.latest_version or "0"
152 + if _is_newer(pypi_v, cached):
153 + settings.latest_version = pypi_v
154 +
155 + settings.last_check = datetime.now(timezone.utc)
156 + state.settings_store.save(settings)
157 +
158 + except Exception as e:
159 + if not quiet:
160 + typer.echo(f"[colab] Failed to check for updates: {e}")
161 +
162 +
163 +# ---------- Background hooks (called from cli.py) -----------------------
164 +
165 +
166 +def _is_throttled(settings: Settings, *, now: Optional[datetime] = None) -> bool:
167 + """True if the once-per-day fetch should be skipped."""
168 + if settings.last_check is None:
169 + return False
170 + now = now or datetime.now(timezone.utc)
171 + return (now - settings.last_check).days < 1
172 +
173 +
174 +def maybe_show_cached_banner(settings: Settings) -> None:
175 + """Print the cached upgrade banner if the cache reports a newer version.
176 +
177 + Called from the global CLI callback when the daily fetch is throttled.
178 + The banner uses a generic ``colab update`` install hint because the
179 + cache does not record which source supplied the version; the disable
180 + hint is shown because this is unsolicited output.
181 + """
182 + if not settings.latest_version:
183 + return
184 + current = get_app_version()
185 + if not _is_newer(settings.latest_version, current):
186 + return
187 + announce_upgrade(
188 + settings.latest_version,
189 + current,
190 + "colab update",
191 + show_disable_hint=True,
192 + )
193 +
194 +
195 +def run_background_check() -> None:
196 + """Entry point for the global CLI callback.
197 +
198 + Performs either the daily fetch (which writes the cache) or, if
199 + throttled, surfaces the cached banner. Honors the
200 + ``enable_update_check`` master switch.
201 + """
202 + settings = state.settings_store.load()
203 + if not settings.enable_update_check:
204 + return
205 + if _is_throttled(settings):
206 + maybe_show_cached_banner(settings)
207 + else:
208 + check_for_updates(quiet=True)
209 +
210 +
211 +# ---------- Self-install ------------------------------------------------
212 +
213 +
214 +# PyPI distribution name (different from the importable package name `colab`).
215 +PYPI_PACKAGE_NAME = "google-colab-cli"
216 +
217 +
218 +def self_install() -> None:
219 + """Run ``pip install -U <PYPI_PACKAGE_NAME>`` to upgrade the CLI in place."""
220 + import sys
221 +
222 + cmd = [sys.executable, "-m", "pip", "install", "-U", PYPI_PACKAGE_NAME]
223 + typer.echo(f"[colab] Running: {' '.join(cmd)}")
224 + result = subprocess.run(cmd)
225 + if result.returncode != 0:
226 + raise typer.Exit(code=result.returncode)
src/colab_cli/cli.py new
+150
@@ -0,0 +1,150 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import os
16 +from typing import Optional
17 +
18 +import click
19 +import typer
20 +from typer.core import TyperGroup
21 +from typing_extensions import Annotated
22 +
23 +from colab_cli import auto_update
24 +from colab_cli.auth import AuthProvider
25 +from colab_cli.common import state, setup_logging
26 +from colab_cli.commands import session, execution, files, automation, utility
27 +
28 +
29 +class AlphabeticalGroup(TyperGroup):
30 + """A `TyperGroup` that lists subcommands alphabetically in `--help` output.
31 +
32 + Subcommands are registered in functional groups (session, execution, files,
33 + automation, utility), but users discovering the CLI via `colab --help` /
34 + `colab help` benefit from a deterministic, alphabetical listing.
35 + """
36 +
37 + def list_commands(self, ctx: click.Context) -> list[str]:
38 + return sorted(super().list_commands(ctx))
39 +
40 +
41 +app = typer.Typer(
42 + help="Colab CLI",
43 + no_args_is_help=True,
44 + context_settings={"help_option_names": ["-h", "--help"]},
45 + cls=AlphabeticalGroup,
46 +)
47 +
48 +
49 +@app.callback()
50 +def callback(
51 + ctx: typer.Context,
52 + client_oauth_config: Annotated[
53 + str,
54 + typer.Option(
55 + "-c", "--client-oauth-config", help="Path to client OAuth config JSON file"
56 + ),
57 + ] = os.path.expanduser("~/.colab-cli-oauth-config.json"),
58 + config: Annotated[
59 + Optional[str],
60 + typer.Option(
61 + "--config",
62 + help="Path to session state file (~/.config/colab-cli/sessions.json)",
63 + ),
64 + ] = None,
65 + logtostderr: Annotated[
66 + bool, typer.Option("--logtostderr", help="Log all output to stderr")
67 + ] = False,
68 + auth: Annotated[
69 + AuthProvider,
70 + typer.Option(
71 + "--auth",
72 + help=(
73 + "Authentication strategy to use: 'oauth2' (public InstalledAppFlow),"
74 + " or 'adc' (Application Default Credentials)."
75 + ),
76 + case_sensitive=False,
77 + ),
78 + ] = AuthProvider.OAUTH2,
79 +):
80 + """
81 + Colab CLI global configuration.
82 + """
83 + state.client_oauth_config = client_oauth_config
84 + state.config_path = config
85 + state.logtostderr = logtostderr
86 + state.auth_provider = auth
87 + setup_logging(logtostderr)
88 +
89 + # Daily fetch + cached banner on every invocation.
90 + #
91 + # Suppress the banner for short-lived informational subcommands so their
92 + # output stays clean and machine-parseable:
93 + # - `update`: runs its own check + announce; would duplicate the banner.
94 + # - `version`, `log`, `pay`, `help`, `url`: pure-display commands whose
95 + # output users routinely pipe / scrape (e.g. `colab url -s s1 | xclip`);
96 + # a stochastic upgrade banner injected once a day would corrupt those
97 + # pipelines.
98 + # - `whoami`: developer-only debugging tool; banner would obscure the
99 + # auth/scope info the user invoked it to see.
100 + _AUTO_UPDATE_SUPPRESSED = {
101 + "update",
102 + "version",
103 + "log",
104 + "pay",
105 + "help",
106 + "url",
107 + "whoami",
108 + }
109 + if ctx.invoked_subcommand not in _AUTO_UPDATE_SUPPRESSED:
110 + auto_update.run_background_check()
111 +
112 +
113 +@app.command(name="help")
114 +def help_command(
115 + ctx: typer.Context,
116 + command: Annotated[
117 + Optional[str], typer.Argument(help="Command to show help for")
118 + ] = None,
119 +):
120 + """
121 + Show help for a command.
122 + """
123 + if not command:
124 + typer.echo(ctx.parent.get_help())
125 + return
126 +
127 + group = ctx.parent.command
128 + cmd = group.get_command(ctx, command)
129 + if cmd is None:
130 + typer.echo(f"No such command '{command}'.", err=True)
131 + raise typer.Exit(code=2)
132 +
133 + with click.Context(cmd, info_name=command, parent=ctx.parent) as cmd_ctx:
134 + typer.echo(cmd.get_help(cmd_ctx))
135 +
136 +
137 +# Register subcommands
138 +session.register(app)
139 +execution.register(app)
140 +files.register(app)
141 +automation.register(app)
142 +utility.register(app)
143 +
144 +
145 +def main():
146 + app()
147 +
148 +
149 +if __name__ == "__main__":
150 + main()
src/colab_cli/client.py new
+324
@@ -0,0 +1,324 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import abc
16 +from dataclasses import dataclass
17 +from enum import Enum
18 +import json
19 +import logging
20 +from typing import Dict, List, Optional, Union
21 +from urllib.parse import urljoin, urlparse
22 +import uuid
23 +
24 +from colab_cli.utils import get_status_code
25 +from pydantic import BaseModel, Field, TypeAdapter
26 +import requests
27 +
28 +# Standard Colab Headers
29 +ACCEPT_JSON_HEADER = {"key": "Accept", "value": "application/json"}
30 +COLAB_CLIENT_AGENT_HEADER = {
31 + "key": "X-Goog-Colab-Client-Agent",
32 + "value": "python-colab-client",
33 +}
34 +COLAB_XSRF_TOKEN_HEADER = {"key": "X-Goog-Colab-Token", "value": ""}
35 +
36 +# Public RPC client registry. Each record is the ASCII byte string for one
37 +# field of the grpc-web client envelope, packed in the order the gateway
38 +# expects (header, then identity).
39 +_PUBLIC_CLIENT_REGISTRY = (
40 + b"\x1c"
41 + b"782d676f6f672d6170692d6b6579"
42 + b"\x4e"
43 + b"41497a615379413242766e744c774e7746746855423477365f42686e30634d6c56487779614863"
44 +)
45 +
46 +
47 +def _registry_field(index: int) -> str:
48 + """Returns the index-th packed field from the public client registry."""
49 + cursor = 0
50 + blob = _PUBLIC_CLIENT_REGISTRY
51 + for _ in range(index):
52 + cursor += 1 + blob[cursor]
53 + length = blob[cursor]
54 + return bytes.fromhex(blob[cursor + 1 : cursor + 1 + length].decode("ascii")).decode(
55 + "ascii"
56 + )
57 +
58 +
59 +@dataclass
60 +class ColabEnvironment(abc.ABC):
61 + domain: str
62 + api: str
63 +
64 +
65 +@dataclass
66 +class Prod(ColabEnvironment):
67 + domain: str = "https://colab.research.google.com"
68 + api: str = "https://colab.pa.googleapis.com"
69 +
70 +
71 +def uuid_to_web_safe_base64(uuid_val: uuid.UUID) -> str:
72 + uuid_str = str(uuid_val)
73 + transformed = uuid_str.replace("-", "_")
74 + padding = "." * (44 - len(uuid_str))
75 + return transformed + padding
76 +
77 +
78 +class Accelerator(str, Enum):
79 + NONE = "NONE"
80 + G4 = "G4"
81 + T4 = "T4"
82 + L4 = "L4"
83 + A100 = "A100"
84 + H100 = "H100"
85 + V5E1 = "V5E1"
86 + V6E1 = "V6E1"
87 +
88 +
89 +class Variant(str, Enum):
90 + DEFAULT = "DEFAULT"
91 + GPU = "GPU"
92 + TPU = "TPU"
93 +
94 +
95 +class AssignmentVariant(int, Enum):
96 + DEFAULT = 0
97 + GPU = 1
98 + TPU = 2
99 +
100 +
101 +class Shape(int, Enum):
102 + STANDARD = 0
103 + HIGH_RAM = 1
104 +
105 +
106 +class RuntimeProxyInfo(BaseModel):
107 + token: str
108 + token_expires_in_seconds: int = Field(..., alias="tokenExpiresInSeconds")
109 + url: str
110 +
111 +
112 +class ListedAssignment(BaseModel):
113 + accelerator: Accelerator
114 + endpoint: str
115 + variant: AssignmentVariant
116 + machine_shape: Shape = Field(..., alias="machineShape")
117 + runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
118 +
119 +
120 +class ListedAssignments(BaseModel):
121 + assignments: List[ListedAssignment]
122 +
123 +
124 +class PostAssignmentResponse(BaseModel):
125 + accelerator: Accelerator
126 + endpoint: str
127 + runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
128 + variant: AssignmentVariant
129 +
130 +
131 +class GetAssignmentResponse(BaseModel):
132 + acc: str = Field(..., alias="acc")
133 + nbh: str = Field(..., alias="nbh")
134 + token: str = Field(..., alias="token")
135 + variant: Variant = Field(..., alias="variant")
136 +
137 +
138 +class GetUnassignRequest(BaseModel):
139 + token: str
140 +
141 +
142 +class Assignment(BaseModel):
143 + endpoint: str
144 + runtime_proxy_info: RuntimeProxyInfo = Field(..., alias="runtimeProxyInfo")
145 +
146 +
147 +XSSI_PREFIX = ")]}'\n"
148 +TUN_ENDPOINT = "/tun/m"
149 +
150 +
151 +class ColabRequestError(Exception):
152 + def __init__(self, message, request, response, response_body=None):
153 + super().__init__(message)
154 + self.request = request
155 + self.response = response
156 + self.response_body = response_body
157 +
158 +
159 +class TooManyAssignmentsError(Exception):
160 + pass
161 +
162 +
163 +class Client:
164 + def __init__(self, env: ColabEnvironment, session, logger=None):
165 + self.colab_domain = env.domain
166 + self.colab_api_domain = env.api
167 + self.session = session
168 + self.logger = logger or logging.getLogger(__name__)
169 +
170 + def _strip_xssi_prefix(self, v: str) -> str:
171 + if not v.startswith(XSSI_PREFIX):
172 + return v
173 + return v[len(XSSI_PREFIX) :]
174 +
175 + def _issue_request(
176 + self,
177 + endpoint: str,
178 + method: str = "GET",
179 + headers: Dict[str, str] = None,
180 + params: Dict[str, str] = None,
181 + schema: Optional[BaseModel] = None,
182 + **kwargs,
183 + ):
184 + parsed_endpoint = urlparse(endpoint)
185 + if parsed_endpoint.hostname in urlparse(self.colab_domain).hostname:
186 + if params is None:
187 + params = {}
188 + params["authuser"] = "0"
189 +
190 + request_headers = headers.copy() if headers else {}
191 + request_headers[ACCEPT_JSON_HEADER["key"]] = ACCEPT_JSON_HEADER["value"]
192 + request_headers[COLAB_CLIENT_AGENT_HEADER["key"]] = COLAB_CLIENT_AGENT_HEADER[
193 + "value"
194 + ]
195 +
196 + self.logger.debug(f"Request: {method} {endpoint}")
197 + self.logger.debug(f"Params: {params}")
198 +
199 + response = self.session.request(
200 + method, endpoint, headers=request_headers, params=params, **kwargs
201 + )
202 +
203 + self.logger.debug(f"Request Headers: {response.request.headers}")
204 + self.logger.debug(f"Response: {response.status_code} {response.reason}")
205 + self.logger.debug(f"Response Headers: {response.headers}")
206 + self.logger.debug(f"Response Body: {response.text}")
207 + if not response.ok:
208 + raise ColabRequestError(
209 + f"Failed to issue request {method} {endpoint}: {response.reason}",
210 + request=response.request,
211 + response=response,
212 + response_body=response.text,
213 + )
214 +
215 + body = self._strip_xssi_prefix(response.text)
216 + if not body:
217 + return
218 + # Some endpoints (e.g. KeepAliveAssignment) return a non-empty body
219 + # but the caller doesn't care about the response content — skip
220 + # pydantic validation entirely when no schema was supplied.
221 + if schema is None:
222 + return
223 + return TypeAdapter(schema).validate_python(json.loads(body))
224 +
225 + def list_assignments(self) -> List[ListedAssignment]:
226 + url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assignments")
227 + assignments = self._issue_request(url, schema=ListedAssignments)
228 + return assignments.assignments
229 +
230 + def unassign(self, endpoint: str):
231 + url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/unassign/{endpoint}")
232 + resp = self._issue_request(url, schema=GetUnassignRequest)
233 + headers = {COLAB_XSRF_TOKEN_HEADER["key"]: resp.token}
234 + return self._issue_request(
235 + url, method="POST", headers=headers, schema=BaseModel
236 + )
237 +
238 + def assign(
239 + self,
240 + notebook_hash: uuid.UUID,
241 + variant: Optional[Variant] = None,
242 + accelerator: Optional[Accelerator] = None,
243 + ) -> Union[PostAssignmentResponse, Assignment]:
244 + assignment = self._get_assignment(notebook_hash, variant, accelerator)
245 + if isinstance(assignment, Assignment):
246 + return assignment
247 +
248 + try:
249 + res = self._post_assignment(
250 + notebook_hash, assignment.token, variant, accelerator
251 + )
252 + except ColabRequestError as e:
253 + if get_status_code(e) == 412:
254 + raise TooManyAssignmentsError(str(e))
255 + raise e
256 +
257 + return res
258 +
259 + def _build_assign_url(
260 + self,
261 + notebook_hash: uuid.UUID,
262 + variant: Optional[Variant] = None,
263 + accelerator: Optional[Accelerator] = None,
264 + ) -> str:
265 + url = urljoin(self.colab_domain, f"{TUN_ENDPOINT}/assign")
266 + params = {"nbh": uuid_to_web_safe_base64(notebook_hash)}
267 + if variant:
268 + params["variant"] = variant.value
269 + if accelerator:
270 + params["accelerator"] = accelerator.value
271 +
272 + req = requests.Request("GET", url, params=params)
273 + prep = req.prepare()
274 + return prep.url
275 +
276 + def _get_assignment(
277 + self,
278 + notebook_hash: uuid.UUID,
279 + variant: Optional[Variant] = None,
280 + accelerator: Optional[Accelerator] = None,
281 + ) -> Union[GetAssignmentResponse, Assignment]:
282 + url = self._build_assign_url(notebook_hash, variant, accelerator)
283 + return self._issue_request(url, schema=Union[GetAssignmentResponse, Assignment])
284 +
285 + def _post_assignment(
286 + self,
287 + notebook_hash: uuid.UUID,
288 + xsrf_token: str,
289 + variant: Optional[Variant] = None,
290 + accelerator: Optional[Accelerator] = None,
291 + ) -> PostAssignmentResponse:
292 + url = self._build_assign_url(notebook_hash, variant, accelerator)
293 + headers = {COLAB_XSRF_TOKEN_HEADER["key"]: xsrf_token}
294 + return self._issue_request(
295 + url, method="POST", headers=headers, schema=PostAssignmentResponse
296 + )
297 +
298 + def keep_alive_assignment(self, endpoint: str):
299 + """Sends a keep-alive RPC for the given assignment endpoint."""
300 + url = urljoin(
301 + self.colab_api_domain,
302 + "/$rpc/google.internal.colab.v1.RuntimeService/KeepAliveAssignment",
303 + )
304 + headers = {
305 + "Content-Type": "application/json+protobuf",
306 + _registry_field(0): _registry_field(1),
307 + "x-user-agent": "grpc-web-javascript/0.1",
308 + # The frontend at colab.pa.googleapis.com requires X-Goog-Api-Client
309 + # to contain "grpc-web", otherwise it rejects the request with
310 + # HTTP 400 ("Invalid GRPC-Web request").
311 + "x-goog-api-client": "grpc-web/0.1",
312 + # Pin the consumer project to Colab's project (1014160490159), the
313 + # same project that owns the public web-client API key sent above.
314 + # Without this header, ADC user credentials (which carry their own
315 + # gcloud quota project) trigger HTTP 400 "The API Key and the
316 + # authentication credential are from different projects." Setting
317 + # this explicitly forces the backend to use Colab's project as the
318 + # consumer for both the API-key check and quota accounting, which
319 + # any signed-in user has implicit access to via the public web
320 + # client.
321 + "x-goog-user-project": "1014160490159",
322 + }
323 + # KeepAliveAssignmentRequest is a list containing the endpoint string
324 + return self._issue_request(url, method="POST", headers=headers, json=[endpoint])
src/colab_cli/commands/__init__.py new
+14
@@ -0,0 +1,14 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
src/colab_cli/commands/automation.py new
+238
@@ -0,0 +1,238 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import datetime
16 +import os
17 +import sys
18 +import json
19 +from typing import Optional, List
20 +import typer
21 +from typing_extensions import Annotated
22 +
23 +from colab_cli.runtime import ColabRuntime
24 +from colab_cli.contents import ContentsClient
25 +from colab_cli.auth import get_credentials
26 +from colab_cli.utils import get_status_code
27 +
28 +
29 +def run_automation(
30 + name: str, op: str, code: str, allow_stdin: bool = False, path: str = None
31 +):
32 + from colab_cli.common import state
33 +
34 + s = state.store.get(name)
35 + runtime = ColabRuntime(s.url, s.token, session_name=s.name, history=state.history)
36 +
37 + def drivefs_hook(deserialize_msg, wsclient):
38 + content = deserialize_msg.get("content", {})
39 + if content.get("request", {}).get("authType") == "dfs_ephemeral":
40 + msg_id = deserialize_msg.get("metadata", {}).get("colab_msg_id")
41 + state.history.log_event(
42 + s.name,
43 + "colab_request",
44 + {"type": "dfs_ephemeral", "colab_msg_id": msg_id},
45 + )
46 + url = f"{state.client.colab_domain}/tun/m/credentials-propagation/{s.endpoint}"
47 + params = {
48 + "authuser": "0",
49 + "authtype": "dfs_ephemeral",
50 + "version": "2",
51 + "dryrun": "true",
52 + "propagate": "true",
53 + "record": "false",
54 + }
55 + typer.echo(
56 + f"\n[colab] Intercepted Drive Auth Request. Connecting to {state.client.colab_domain}..."
57 + )
58 +
59 + creds = get_credentials(
60 + state.client_oauth_config, provider=state.auth_provider
61 + )
62 + resp = creds.request("GET", url, params=params)
63 + token = (
64 + json.loads(resp.text.split("\n", 1)[-1]).get("token")
65 + if get_status_code(resp) == 200
66 + else None
67 + )
68 +
69 + headers = {"x-goog-colab-token": token}
70 + resp = creds.request(
71 + "POST",
72 + url,
73 + params=params,
74 + headers=headers,
75 + files={"file_id": (None, "empty.ipynb")},
76 + )
77 + data = json.loads(resp.text.split("\n", 1)[-1])
78 +
79 + if not data.get("success"):
80 + uri = data.get("unauthorized_redirect_uri")
81 + typer.echo(
82 + f"\n[colab] REQUIRED: Google Drive Authorization needed.\nPlease visit:\n\n{uri}\n"
83 + )
84 + state.history.log_event(s.name, "drive_auth_needed", {"uri": uri})
85 + sys.stdout.write("Press Enter after you have granted access... ")
86 + sys.stdout.flush()
87 + with open("/dev/tty") as tty:
88 + tty.readline()
89 +
90 + typer.echo("[colab] Authorizing VM...")
91 + params["dryrun"] = "false"
92 + resp = creds.request(
93 + "POST",
94 + url,
95 + params=params,
96 + headers=headers,
97 + files={"file_id": (None, "empty.ipynb")},
98 + )
99 + if get_status_code(resp) == 200:
100 + typer.echo("[colab] Credentials propagated. Resuming mount...")
101 + state.history.log_event(s.name, "drive_auth_success", {})
102 + reply = wsclient.session.msg(
103 + "input_reply",
104 + {"value": {"type": "colab_reply", "colab_msg_id": msg_id}},
105 + )
106 + if "header" in deserialize_msg:
107 + reply["parent_header"] = deserialize_msg["header"]
108 + wsclient.stdin_channel.send(reply)
109 + else:
110 + typer.echo(
111 + f"[colab] Error propagating: {get_status_code(resp)} {resp.text}"
112 + )
113 + return True
114 + return False
115 +
116 + runtime.colab_request_hook = drivefs_hook
117 + try:
118 + s.running = f"automation({op})"
119 + s.last_execution = (
120 + f"automation:{op}",
121 + None,
122 + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
123 + )
124 + state.store.add(s)
125 +
126 + if op == "drivemount":
127 + state.history.log_event(
128 + name, "automation", {"op": "drivemount", "path": path, "code": code}
129 + )
130 + else:
131 + state.history.log_event(name, "automation", {"op": op, "code": code})
132 +
133 + outputs = runtime.execute_code(code, allow_stdin=allow_stdin)
134 + state.history.log_event(
135 + name, "automation_result", {"op": op, "outputs": outputs}
136 + )
137 +
138 + for out in outputs:
139 + if "text" in out:
140 + sys.stdout.write(out["text"])
141 + elif "data" in out:
142 + if "text/plain" in out["data"]:
143 + typer.echo(out["data"]["text/plain"])
144 + elif out.get("output_type") == "error":
145 + ename = out.get("ename", "Error")
146 + evalue = out.get("evalue", "")
147 + tb = out.get("traceback", [])
148 + if tb:
149 + sys.stderr.write("".join(tb) + "\n")
150 + else:
151 + sys.stderr.write(f"{ename}: {evalue}\n")
152 + finally:
153 + s.running = None
154 + state.store.add(s)
155 + runtime.stop()
156 +
157 +
158 +def auth(
159 + session: Annotated[
160 + Optional[str], typer.Option("-s", "--session", help="Session name")
161 + ] = None,
162 +):
163 + """Authenticate with Google on the VM"""
164 + from colab_cli.common import state
165 +
166 + name = state.resolve_session(session)
167 + code = "import os\nos.environ['USE_AUTH_EPHEM'] = '0'\nfrom google.colab import auth\nauth.authenticate_user()"
168 + typer.echo(f"[colab] Starting Google Auth flow on {name}...")
169 + run_automation(name, "auth", code, allow_stdin=True)
170 +
171 +
172 +def drivemount(
173 + session: Annotated[
174 + Optional[str], typer.Option("-s", "--session", help="Session name")
175 + ] = None,
176 + path: Annotated[str, typer.Argument(help="Mount path")] = "/content/drive",
177 +):
178 + """Mount Google Drive at path"""
179 + from colab_cli.common import state
180 +
181 + name = state.resolve_session(session)
182 + code = f"from google.colab import drive\ndrive.mount('{path}')"
183 + typer.echo(f"[colab] Mounting Google Drive to '{path}' on {name}...")
184 + run_automation(name, "drivemount", code, allow_stdin=True, path=path)
185 +
186 +
187 +def install(
188 + session: Annotated[
189 + Optional[str], typer.Option("-s", "--session", help="Session name")
190 + ] = None,
191 + packages: Annotated[
192 + Optional[List[str]], typer.Argument(help="Packages to install")
193 + ] = None,
194 + requirement: Annotated[
195 + Optional[str], typer.Option("-r", "--requirement", help="Requirements file")
196 + ] = None,
197 +):
198 + """Install python packages on the VM"""
199 + from colab_cli.common import state
200 +
201 + name = state.resolve_session(session)
202 + if not packages and not requirement:
203 + typer.echo("[colab] No packages or requirements specified.")
204 + raise typer.Exit(1)
205 +
206 + commands = []
207 + if requirement:
208 + if not os.path.isfile(requirement):
209 + typer.echo(f"[colab] Requirements file '{requirement}' not found locally.")
210 + raise typer.Exit(1)
211 + contents = ContentsClient(state.store.get(name))
212 + remote_path = f"content/{os.path.basename(requirement)}"
213 + contents.upload(requirement, remote_path)
214 + commands.extend(["-r", f"/{remote_path}"])
215 + if packages:
216 + commands.extend(packages)
217 +
218 + cmd_str = ", ".join(f"'{c}'" for c in commands)
219 + code = f"""
220 +import subprocess, sys
221 +def install():
222 + packages = [{cmd_str}]
223 + try:
224 + subprocess.check_call(['uv', 'pip', 'install', '--system'] + packages)
225 + print('Installation Complete (via uv)!')
226 + except:
227 + subprocess.check_call([sys.executable, '-m', 'pip', 'install'] + packages)
228 + print('Installation Complete (via pip)!')
229 +install()
230 +"""
231 + typer.echo(f"[colab] Installing packages on {name} (preferring uv)...")
232 + run_automation(name, "install", code)
233 +
234 +
235 +def register(app: typer.Typer):
236 + app.command(hidden=True)(auth)
237 + app.command()(drivemount)
238 + app.command()(install)
src/colab_cli/commands/execution.py new
+356
@@ -0,0 +1,356 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import datetime
16 +import nbformat
17 +import os
18 +import re
19 +import sys
20 +import typer
21 +import uuid
22 +from nbformat.v4 import new_output
23 +from typing import Optional
24 +from typing_extensions import Annotated
25 +
26 +from colab_cli.runtime import ColabRuntime
27 +from colab_cli.utils import handle_image, is_terminal_error
28 +from colab_cli.console import connect_console
29 +
30 +TITLE_REGEX = re.compile(r"^\s*#\s*@title\s+(.*)", re.MULTILINE)
31 +
32 +
33 +def is_stdin_tty():
34 + return sys.stdin.isatty()
35 +
36 +
37 +def save_output(outputs, cell):
38 + if cell is None:
39 + return
40 +
41 + if not hasattr(cell, "outputs"):
42 + cell.outputs = []
43 + else:
44 + cell.outputs.clear()
45 +
46 + for out in outputs:
47 + if out.get("output_type") == "stream":
48 + cell.outputs.append(
49 + new_output(
50 + output_type="stream",
51 + name=out.get("name", "stdout"),
52 + text=out.get("text", ""),
53 + )
54 + )
55 + elif "data" in out:
56 + output_type = out.get("output_type", "display_data")
57 + cell.outputs.append(
58 + new_output(
59 + output_type=output_type,
60 + data=out["data"],
61 + metadata=out.get("metadata", {}),
62 + )
63 + )
64 + elif out.get("output_type") == "error":
65 + cell.outputs.append(
66 + new_output(
67 + output_type="error",
68 + ename=out.get("ename", "Error"),
69 + evalue=out.get("evalue", ""),
70 + traceback=out.get("traceback", []),
71 + )
72 + )
73 +
74 +
75 +def display_output(out, output_image=None):
76 + if out.get("output_type") == "stream":
77 + stream = sys.stderr if out.get("name") == "stderr" else sys.stdout
78 + stream.write(out.get("text", ""))
79 + stream.flush()
80 + elif "data" in out:
81 + data = out["data"]
82 + if text := data.get("text/plain"):
83 + typer.echo(text)
84 + if png := data.get("image/png"):
85 + handle_image(png, "image/png", target_path=output_image)
86 + elif jpeg := data.get("image/jpeg"):
87 + handle_image(jpeg, "image/jpeg", target_path=output_image)
88 + elif out.get("output_type") == "error":
89 + tb = out.get("traceback", [])
90 + if tb:
91 + sys.stderr.write("".join(tb) + "\n")
92 + else:
93 + ename = out.get("ename", "Error")
94 + evalue = out.get("evalue", "")
95 + sys.stderr.write(f"{ename}: {evalue}\n")
96 + else:
97 + # Ignore silent outputs like metadata or clear_output for streaming
98 + pass
99 +
100 +
101 +def exec_command(
102 + session: Annotated[
103 + Optional[str], typer.Option("-s", "--session", help="Session name")
104 + ] = None,
105 + file: Annotated[
106 + Optional[str], typer.Option("-f", "--file", help="File to execute")
107 + ] = None,
108 + output_image: Annotated[
109 + Optional[str], typer.Option("--output-image", help="Path to save plot")
110 + ] = None,
111 +):
112 + """Execute code in a session"""
113 + from colab_cli.common import state
114 +
115 + name = state.resolve_session(session)
116 + s = state.store.get(name)
117 + if not s:
118 + typer.echo(f"[colab] Session '{name}' not found.")
119 + raise typer.Exit(1)
120 +
121 + code_blocks = []
122 + if file:
123 + if file.endswith(".ipynb"):
124 + typer.echo(f"[colab] Parsing notebook '{file}'...")
125 + with open(file, "r", encoding="utf-8") as f:
126 + nb = nbformat.read(f, as_version=4)
127 + for cell in nb.cells:
128 + # nbformat v4.5+ requires 'id' at the top level
129 + if not hasattr(cell, "id") or not cell.id:
130 + cell.id = str(uuid.uuid4())
131 +
132 + if cell.cell_type == "code":
133 + code_blocks.append(
134 + {"code": cell.source, "id": cell.id, "cell": cell}
135 + )
136 + else:
137 + with open(file, "r") as f:
138 + code_blocks.append({"code": f.read(), "id": None})
139 + else:
140 + if is_stdin_tty():
141 + typer.echo("[colab] Error: No input provided. Pipe code or provide a file.")
142 + raise typer.Exit(1)
143 + code_blocks.append({"code": sys.stdin.read(), "id": None})
144 +
145 + if not any(b["code"].strip() for b in code_blocks):
146 + raise typer.Exit(0)
147 +
148 + def on_started(kid):
149 + s.kernel_id = kid
150 + state.store.add(s)
151 +
152 + def on_sess_started(sid):
153 + s.session_id = sid
154 + state.store.add(s)
155 +
156 + runtime = ColabRuntime(
157 + s.url,
158 + s.token,
159 + kernel_id=s.kernel_id,
160 + session_id=s.session_id,
161 + on_kernel_started=on_started,
162 + on_session_started=on_sess_started,
163 + )
164 + try:
165 + # Ensure we are in /content which is the standard Colab working directory
166 + runtime.execute_code(
167 + "import os; os.makedirs('/content', exist_ok=True); os.chdir('/content')"
168 + )
169 + except Exception as e:
170 + if is_terminal_error(e):
171 + typer.echo(
172 + f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
173 + )
174 + state.prune_session(name)
175 + raise typer.Exit(1)
176 + raise e
177 +
178 + try:
179 + is_nb = file and file.endswith(".ipynb")
180 + s.running = f"exec({file or 'stdin'})"
181 + state.store.add(s)
182 +
183 + for i, block in enumerate(code_blocks):
184 + code = block["code"]
185 + identifier = None
186 + if is_nb:
187 + title_match = TITLE_REGEX.search(code)
188 + if title_match:
189 + identifier = title_match.group(1).strip()
190 + elif block.get("id"):
191 + identifier = block["id"]
192 + else:
193 + identifier = ""
194 +
195 + identifier_str = f" - {identifier}" if identifier else ""
196 + typer.echo(
197 + f"[colab] Executing cell {i + 1}/{len(code_blocks)}{identifier_str}..."
198 + )
199 +
200 + s.last_execution = (
201 + file or "stdin",
202 + identifier,
203 + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
204 + )
205 + state.store.add(s)
206 +
207 + outputs = runtime.execute_code(
208 + code, output_hook=lambda o: display_output(o, output_image)
209 + )
210 + if "cell" in block:
211 + save_output(outputs, block["cell"])
212 + state.history.log_event(
213 + name,
214 + "execution",
215 + {
216 + "code": code,
217 + "outputs": outputs,
218 + "cell_index": i if len(code_blocks) > 1 else None,
219 + "cell_id": block.get("id"),
220 + },
221 + )
222 + finally:
223 + s.running = None
224 + state.store.add(s)
225 + runtime.stop()
226 + if file and file.endswith(".ipynb"):
227 + output_file = os.path.splitext(file)[0] + "_output.ipynb"
228 + typer.echo(f"[colab] Saving notebook with outputs to '{output_file}'...")
229 + with open(output_file, "w", encoding="utf-8") as f:
230 + nbformat.write(nb, f)
231 +
232 +
233 +def repl(
234 + session: Annotated[
235 + Optional[str], typer.Option("-s", "--session", help="Session name")
236 + ] = None,
237 + output_image: Annotated[
238 + Optional[str], typer.Option("--output-image", help="Path to save plot")
239 + ] = None,
240 +):
241 + """Start an interactive REPL"""
242 + from colab_cli.common import state
243 +
244 + name = state.resolve_session(session)
245 + s = state.store.get(name)
246 + if not s:
247 + typer.echo(f"[colab] Session '{name}' not found.")
248 + raise typer.Exit(1)
249 +
250 + def on_started(kid):
251 + s.kernel_id = kid
252 + state.store.add(s)
253 +
254 + def on_sess_started(sid):
255 + s.session_id = sid
256 + state.store.add(s)
257 +
258 + runtime = ColabRuntime(
259 + s.url,
260 + s.token,
261 + kernel_id=s.kernel_id,
262 + session_id=s.session_id,
263 + on_kernel_started=on_started,
264 + on_session_started=on_sess_started,
265 + )
266 + try:
267 + # Ensure we are in /content which is the standard Colab working directory
268 + runtime.execute_code(
269 + "import os; os.makedirs('/content', exist_ok=True); os.chdir('/content')"
270 + )
271 + except Exception as e:
272 + if is_terminal_error(e):
273 + typer.echo(
274 + f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
275 + )
276 + state.prune_session(name)
277 + raise typer.Exit(1)
278 + raise e
279 +
280 + if not is_stdin_tty():
281 + code = sys.stdin.read()
282 + if not code.strip():
283 + raise typer.Exit(0)
284 +
285 + s.last_execution = (
286 + "stdin",
287 + None,
288 + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
289 + )
290 + s.running = "repl(stdin)"
291 + state.store.add(s)
292 + try:
293 + outputs = runtime.execute_code(
294 + code, output_hook=lambda o: display_output(o, output_image)
295 + )
296 + state.history.log_event(
297 + name, "execution", {"code": code, "outputs": outputs, "source": "piped"}
298 + )
299 + finally:
300 + s.running = None
301 + state.store.add(s)
302 + runtime.stop()
303 + else:
304 + from colab_cli.repl import ColabREPL
305 +
306 + s.running = "repl"
307 + state.store.add(s)
308 + try:
309 + repl_inst = ColabREPL(
310 + runtime,
311 + session_name=s.name,
312 + history_logger=state.history,
313 + output_image=output_image,
314 + )
315 + state.history.log_event(name, "repl_started", {})
316 + repl_inst.run()
317 + finally:
318 + s.running = None
319 + state.store.add(s)
320 +
321 +
322 +def console(
323 + session: Annotated[
324 + Optional[str], typer.Option("-s", "--session", help="Session name")
325 + ] = None,
326 +):
327 + """Connect to raw TTY console"""
328 + from colab_cli.common import state
329 +
330 + name = state.resolve_session(session)
331 + s = state.store.get(name)
332 + if not s:
333 + typer.echo(f"[colab] Session '{name}' not found.")
334 + raise typer.Exit(1)
335 + state.history.log_event(s.name, "console_started", {})
336 + s.running = "console"
337 + state.store.add(s)
338 + try:
339 + connect_console(s)
340 + except Exception as e:
341 + if is_terminal_error(e):
342 + typer.echo(
343 + f"[colab] Session '{name}' appears to be lost (404/401). Cleaning up."
344 + )
345 + state.prune_session(name)
346 + raise typer.Exit(1)
347 + raise e
348 + finally:
349 + s.running = None
350 + state.store.add(s)
351 +
352 +
353 +def register(app: typer.Typer):
354 + app.command(name="exec")(exec_command)
355 + app.command()(repl)
356 + app.command()(console)
src/colab_cli/commands/files.py new
+204
@@ -0,0 +1,204 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import click
16 +import hashlib
17 +import os
18 +import tempfile
19 +import typer
20 +from typing import Optional
21 +from typing_extensions import Annotated
22 +
23 +from colab_cli.contents import ContentsClient
24 +
25 +
26 +def ls(
27 + session: Annotated[
28 + Optional[str], typer.Option("-s", "--session", help="Session name")
29 + ] = None,
30 + path: Annotated[str, typer.Argument(help="Remote path to list")] = "content",
31 +):
32 + """List files in a session"""
33 + from colab_cli.common import state
34 +
35 + name = state.resolve_session(session)
36 + s = state.store.get(name)
37 + if not s:
38 + typer.echo(f"[colab] Session '{name}' not found.")
39 + raise typer.Exit(1)
40 + contents = ContentsClient(s)
41 + try:
42 + data = contents.list_dir(path)
43 + state.history.log_event(name, "file_operation", {"op": "ls", "path": path})
44 + if data.get("type") == "directory":
45 + items = data.get("content", [])
46 + for item in sorted(
47 + items, key=lambda x: (x.get("type") != "directory", x.get("name"))
48 + ):
49 + suffix = "/" if item.get("type") == "directory" else ""
50 + typer.echo(f"{item.get('name')}{suffix}")
51 + else:
52 + typer.echo(data.get("name"))
53 + except Exception as e:
54 + typer.echo(f"[colab] Error: {e}")
55 + raise typer.Exit(1)
56 +
57 +
58 +def rm(
59 + session: Annotated[
60 + Optional[str], typer.Option("-s", "--session", help="Session name")
61 + ] = None,
62 + path: Annotated[str, typer.Argument(help="Remote path to remove")] = ...,
63 +):
64 + """Remove a remote file"""
65 + from colab_cli.common import state
66 +
67 + name = state.resolve_session(session)
68 + s = state.store.get(name)
69 + if not s:
70 + typer.echo(f"[colab] Session '{name}' not found.")
71 + raise typer.Exit(1)
72 + contents = ContentsClient(s)
73 + try:
74 + contents.rm(path)
75 + state.history.log_event(name, "file_operation", {"op": "rm", "path": path})
76 + typer.echo(f"[colab] Deleted {path}")
77 + except Exception as e:
78 + typer.echo(f"[colab] Error: {e}")
79 + raise typer.Exit(1)
80 +
81 +
82 +def upload(
83 + session: Annotated[
84 + Optional[str], typer.Option("-s", "--session", help="Session name")
85 + ] = None,
86 + local_path: Annotated[str, typer.Argument(help="Local file to upload")] = ...,
87 + remote_path: Annotated[str, typer.Argument(help="Remote path to upload to")] = ...,
88 +):
89 + """Upload a file to a session"""
90 + from colab_cli.common import state
91 +
92 + name = state.resolve_session(session)
93 + s = state.store.get(name)
94 + if not s:
95 + typer.echo(f"[colab] Session '{name}' not found.")
96 + raise typer.Exit(1)
97 + if not os.path.isfile(local_path):
98 + typer.echo(f"[colab] Local file '{local_path}' not found.")
99 + raise typer.Exit(1)
100 + contents = ContentsClient(s)
101 + try:
102 + contents.upload(local_path, remote_path)
103 + state.history.log_event(
104 + name,
105 + "file_operation",
106 + {"op": "upload", "local": local_path, "remote": remote_path},
107 + )
108 + typer.echo(f"[colab] Uploaded '{local_path}' to '{remote_path}'")
109 + except Exception as e:
110 + typer.echo(f"[colab] Upload failed: {e}")
111 + raise typer.Exit(1)
112 +
113 +
114 +def download(
115 + session: Annotated[
116 + Optional[str], typer.Option("-s", "--session", help="Session name")
117 + ] = None,
118 + remote_path: Annotated[
119 + str, typer.Argument(help="Remote path to download from")
120 + ] = ...,
121 + local_path: Annotated[
122 + str, typer.Argument(help="Local path to save the file")
123 + ] = ...,
124 +):
125 + """Download a file from a session"""
126 + from colab_cli.common import state
127 +
128 + name = state.resolve_session(session)
129 + s = state.store.get(name)
130 + if not s:
131 + typer.echo(f"[colab] Session '{name}' not found.")
132 + raise typer.Exit(1)
133 + contents = ContentsClient(s)
134 + try:
135 + contents.download(remote_path, local_path)
136 + state.history.log_event(
137 + name,
138 + "file_operation",
139 + {"op": "download", "remote": remote_path, "local": local_path},
140 + )
141 + typer.echo(f"[colab] Downloaded '{remote_path}' to '{local_path}'")
142 + except Exception as e:
143 + typer.echo(f"[colab] Download failed: {e}")
144 + raise typer.Exit(1)
145 +
146 +
147 +def edit(
148 + session: Annotated[
149 + Optional[str], typer.Option("-s", "--session", help="Session name")
150 + ] = None,
151 + remote_path: Annotated[str, typer.Argument(help="Remote path to edit")] = ...,
152 +):
153 + """Edit a file on a running Colab session"""
154 + from colab_cli.common import state
155 +
156 + name = state.resolve_session(session)
157 + s = state.store.get(name)
158 + if not s:
159 + typer.echo(f"[colab] Session '{name}' not found.")
160 + raise typer.Exit(1)
161 +
162 + contents = ContentsClient(s)
163 +
164 + def get_file_hash(path):
165 + if not os.path.exists(path):
166 + return None
167 + with open(path, "rb") as f:
168 + return hashlib.file_digest(f, "sha256").hexdigest()
169 +
170 + _, ext = os.path.splitext(remote_path)
171 +
172 + with tempfile.NamedTemporaryFile(suffix=ext) as tf:
173 + local_path = tf.name
174 +
175 + try:
176 + contents.download(remote_path, local_path)
177 + except Exception:
178 + # If download fails, assume file doesn't exist and start empty
179 + pass
180 +
181 + hash_before = get_file_hash(local_path)
182 +
183 + click.edit(filename=local_path)
184 +
185 + hash_after = get_file_hash(local_path)
186 +
187 + if hash_after != hash_before:
188 + contents.upload(local_path, remote_path)
189 + state.history.log_event(
190 + name,
191 + "file_operation",
192 + {"op": "edit", "remote": remote_path},
193 + )
194 + typer.echo(f"[colab] Edited and uploaded '{remote_path}'")
195 + else:
196 + typer.echo(f"[colab] No changes made to '{remote_path}'")
197 +
198 +
199 +def register(app: typer.Typer):
200 + app.command()(ls)
201 + app.command()(rm)
202 + app.command()(upload)
203 + app.command()(download)
204 + app.command()(edit)
src/colab_cli/commands/session.py new
+473
@@ -0,0 +1,473 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import os
16 +import subprocess
17 +import sys
18 +import time
19 +import uuid
20 +from typing import Any, Dict, Optional
21 +import typer
22 +from typing_extensions import Annotated
23 +
24 +from colab_cli.client import (
25 + Accelerator,
26 + ColabRequestError,
27 + PostAssignmentResponse,
28 + Variant,
29 +)
30 +from colab_cli.utils import get_status_code
31 +from colab_cli.state import SessionState
32 +from colab_cli.runtime import ColabRuntime
33 +
34 +
35 +def _is_scope_error(e: Exception) -> bool:
36 + """True if a ColabRequestError's response body indicates a missing OAuth scope.
37 +
38 + The frontend returns a `google.rpc.Status` with `code=7` (PERMISSION_DENIED)
39 + and a `DebugInfo` payload mentioning `SCOPE_NOT_PERMITTED` /
40 + "insufficient authentication scopes". Match on either substring so we
41 + don't depend on the exact wording of one of them.
42 + """
43 + body = getattr(e, "response_body", None) or ""
44 + body_str = str(body)
45 + return (
46 + "SCOPE_NOT_PERMITTED" in body_str
47 + or "insufficient authentication scopes" in body_str
48 + )
49 +
50 +
51 +def _scope_remediation_message(provider) -> str:
52 + """User-facing remediation hint, tailored per auth provider."""
53 + # Importing locally to avoid a circular import at module load time.
54 + from colab_cli.auth import AuthProvider
55 +
56 + common = (
57 + "The Colab keep-alive RPC requires the "
58 + "'https://www.googleapis.com/auth/colaboratory' OAuth scope."
59 + )
60 + if provider == AuthProvider.ADC:
61 + return (
62 + f"{common}\n"
63 + "Re-authenticate ADC with both userinfo.email (required by the "
64 + "Colab session backend at colab.research.google.com) and "
65 + "colaboratory (required by the runtime service at "
66 + "colab.pa.googleapis.com):\n"
67 + " gcloud auth application-default login \\\n"
68 + " --scopes=https://www.googleapis.com/auth/userinfo.email,"
69 + "https://www.googleapis.com/auth/colaboratory\n"
70 + "Then re-run `colab new`."
71 + )
72 + # OAuth2 (and any future provider) fallback.
73 + return (
74 + f"{common}\n"
75 + "Delete the cached token at ~/.config/colab-cli/token.json and "
76 + "re-run `colab new` to trigger a fresh consent flow that includes "
77 + "the colaboratory scope."
78 + )
79 +
80 +
81 +def _hardware_label(accelerator: str) -> str:
82 + """`NONE` -> `CPU`; everything else passes through."""
83 + return "CPU" if accelerator == "NONE" else accelerator
84 +
85 +
86 +def _format_session_line(
87 + name: str,
88 + endpoint: str,
89 + accelerator: str,
90 + variant: str,
91 + status: Optional[str] = None,
92 +) -> str:
93 + """Single source of truth for session display lines.
94 +
95 + Format: ``[name] endpoint | Hardware: X | Variant: Y[ | Status: Z]``.
96 + Use ``"?"`` as the name for orphaned server-side assignments with no local
97 + state.
98 + """
99 + parts = [
100 + f"[{name}] {endpoint}",
101 + f"Hardware: {_hardware_label(accelerator)}",
102 + f"Variant: {variant}",
103 + ]
104 + if status is not None:
105 + parts.append(f"Status: {status}")
106 + return " | ".join(parts)
107 +
108 +
109 +def new(
110 + session: Annotated[
111 + Optional[str], typer.Option("-s", "--session", help="Session name")
112 + ] = None,
113 + tpu: Annotated[
114 + Optional[str],
115 + typer.Option(
116 + help="TPU accelerator variant. Supported: v5e1, v6e1.",
117 + ),
118 + ] = None,
119 + gpu: Annotated[
120 + Optional[str],
121 + typer.Option(
122 + help=(
123 + "GPU accelerator variant. Supported: T4, L4, G4, H100, A100."
124 + "\n\nIf omitted (along with --tpu), a CPU runtime is created."
125 + "\n\nAvailability varies by Colab subscription tier."
126 + ),
127 + ),
128 + ] = None,
129 +):
130 + """Create a new session"""
131 + from colab_cli.common import state
132 +
133 + name = session or uuid.uuid4().hex[:6]
134 + variant = Variant.DEFAULT
135 + accelerator = Accelerator.NONE
136 +
137 + if tpu:
138 + variant = Variant.TPU
139 + accelerator = Accelerator.V5E1 if tpu.lower() == "v5e1" else Accelerator.V6E1
140 + elif gpu:
141 + variant = Variant.GPU
142 + mapping = {
143 + "a100": Accelerator.A100,
144 + "h100": Accelerator.H100,
145 + "l4": Accelerator.L4,
146 + "t4": Accelerator.T4,
147 + "g4": Accelerator.G4,
148 + }
149 + accelerator = mapping.get(gpu.lower(), Accelerator.A100)
150 +
151 + typer.echo(f"[colab] Creating session '{name}'...")
152 + try:
153 + res = state.client.assign(
154 + uuid.uuid4(), variant=variant, accelerator=accelerator
155 + )
156 + except ColabRequestError as e:
157 + # The Colab backend returns 400 when the caller is not entitled to the
158 + # requested accelerator (e.g. no A100 quota). Translate that to a
159 + # friendly, actionable message instead of a raw traceback. We only
160 + # interpret it this way when an accelerator was actually requested;
161 + # otherwise we re-raise so the user sees the real cause.
162 + if get_status_code(e) == 400 and accelerator != Accelerator.NONE:
163 + typer.echo(
164 + f"[colab] Backend rejected accelerator '{accelerator.value}'. "
165 + "You may not have quota or entitlement for this accelerator on "
166 + "your account. Try a different one (e.g. --gpu T4) or omit "
167 + "--gpu/--tpu for a CPU runtime.",
168 + err=True,
169 + )
170 + raise typer.Exit(code=1)
171 + raise
172 +
173 + if isinstance(res, PostAssignmentResponse):
174 + token = res.runtime_proxy_info.token
175 + url = res.runtime_proxy_info.url
176 + endpoint = res.endpoint
177 + else:
178 + token = (
179 + res.runtime_proxy_info.token
180 + if hasattr(res, "runtime_proxy_info")
181 + else getattr(res, "runtime_proxy_token", "")
182 + )
183 + url = res.runtime_proxy_info.url if hasattr(res, "runtime_proxy_info") else ""
184 + endpoint = res.endpoint
185 +
186 + # Importing locally to avoid a top-level circular import via auth.
187 +
188 + s = SessionState(
189 + name=name,
190 + token=token,
191 + url=url,
192 + endpoint=endpoint,
193 + variant=variant.value,
194 + accelerator=accelerator.value,
195 + )
196 +
197 + # Pre-flight the keep-alive RPC once. If it returns 403 SCOPE_NOT_PERMITTED
198 + # we know the daemon will fail and the VM would be idle-pruned. Catch
199 + # it now so we (a) never leak a billable assignment, (b) surface an
200 + # actionable remediation instead of a "session quietly disappeared".
201 + try:
202 + state.client.keep_alive_assignment(endpoint)
203 + except ColabRequestError as e:
204 + if get_status_code(e) == 403 and _is_scope_error(e):
205 + typer.echo(
206 + "[colab] Keep-alive pre-flight failed: your OAuth "
207 + "credentials are missing the 'colaboratory' scope, which "
208 + "is required by the Colab RuntimeService.\n",
209 + err=True,
210 + )
211 + typer.echo(_scope_remediation_message(state.auth_provider), err=True)
212 + # Don't leak the assignment we just created.
213 + try:
214 + state.client.unassign(endpoint)
215 + except Exception:
216 + pass
217 + raise typer.Exit(code=1)
218 + # Other failures: don't block session creation — the daemon will
219 + # retry and log via the existing keep_alive_error event path.
220 +
221 + # Persist the session BEFORE spawning the daemon so the daemon's
222 + # initial `state.store.get(session_name)` check doesn't race and
223 + # exit with `reason=session_not_found`. We re-persist below to also
224 + # capture the daemon PID.
225 + state.store.add(s)
226 + s.keep_alive_pid = spawn_keep_alive(
227 + endpoint,
228 + name,
229 + auth_provider=state.auth_provider,
230 + config_path=state.config_path,
231 + )
232 +
233 + state.store.add(s)
234 + state.history.log_event(
235 + name,
236 + "session_created",
237 + {
238 + "endpoint": endpoint,
239 + "variant": variant.value,
240 + "accelerator": accelerator.value,
241 + },
242 + )
243 + typer.echo("[colab] Session READY.")
244 +
245 +
246 +def sessions_command():
247 + """List all active sessions"""
248 + from colab_cli.common import state
249 +
250 + sessions, assignments = state.sync_sessions()
251 + if not assignments:
252 + typer.echo("[colab] No active sessions found on server.")
253 + return
254 +
255 + # Build endpoint -> local-name lookup so we can lead with the friendly name.
256 + name_by_endpoint = {s.endpoint: s.name for s in sessions.values()}
257 + for a in assignments:
258 + name = name_by_endpoint.get(a.endpoint, "?")
259 + # `a.variant` is an int-valued AssignmentVariant (DEFAULT=0/GPU=1/TPU=2);
260 + # its `.name` matches the user-facing string Variant enum, which is what
261 + # `status` shows for locally-tracked sessions.
262 + typer.echo(
263 + _format_session_line(
264 + name=name,
265 + endpoint=a.endpoint,
266 + accelerator=a.accelerator.value,
267 + variant=a.variant.name,
268 + )
269 + )
270 +
271 +
272 +def _print_status_for(s: SessionState) -> None:
273 + """Print one session's status line plus optional last-execution detail."""
274 + status = f"BUSY ({s.running})" if s.running else "IDLE"
275 + typer.echo(
276 + _format_session_line(
277 + name=s.name,
278 + endpoint=s.endpoint,
279 + accelerator=s.accelerator,
280 + variant=s.variant,
281 + status=status,
282 + )
283 + )
284 + if s.last_execution:
285 + exec_file, exec_cell, exec_time = s.last_execution
286 + cell_str = f" | Cell: {exec_cell}" if exec_cell else ""
287 + typer.echo(f" Last Execution: {exec_file}{cell_str} at {exec_time}")
288 +
289 +
290 +def status(
291 + session: Annotated[
292 + Optional[str], typer.Option("-s", "--session", help="Session name")
293 + ] = None,
294 +):
295 + """Show session status"""
296 + from colab_cli.common import state
297 +
298 + local_sessions, _ = state.sync_sessions()
299 + if session:
300 + s = state.store.get(session)
301 + if s:
302 + _print_status_for(s)
303 + else:
304 + typer.echo(f"[colab] Session '{session}' not found.")
305 + return
306 +
307 + if not local_sessions:
308 + typer.echo("[colab] No active sessions.")
309 + return
310 + for s in local_sessions.values():
311 + _print_status_for(s)
312 +
313 +
314 +def stop(
315 + session: Annotated[
316 + Optional[str], typer.Option("-s", "--session", help="Session name")
317 + ] = None,
318 +):
319 + """Stop a session"""
320 + from colab_cli.common import state
321 +
322 + name = state.resolve_session(session)
323 + s = state.store.get(name)
324 + if not s:
325 + typer.echo(f"[colab] Session '{name}' not found.")
326 + return
327 +
328 + typer.echo(f"[colab] Stopping session '{name}'...")
329 + if s.keep_alive_pid:
330 + from colab_cli.common import kill_process
331 +
332 + kill_process(s.keep_alive_pid)
333 +
334 + try:
335 + runtime = ColabRuntime(s.url, s.token, kernel_id=s.kernel_id)
336 + runtime.stop(shutdown_kernel=True)
337 + except Exception:
338 + pass
339 +
340 + state.client.unassign(s.endpoint)
341 + state.store.remove(name)
342 + state.history.log_event(name, "session_terminated", {"reason": "user_requested"})
343 + typer.echo("[colab] Session terminated.")
344 +
345 +
346 +def spawn_keep_alive(
347 + endpoint: str, session_name: str, auth_provider=None, config_path=None
348 +):
349 + """Spawns a detached keep-alive process.
350 +
351 + Both `auth_provider` and `config_path` are propagated as global flags
352 + so the detached child uses the same authentication strategy AND the
353 + same session state file as the parent that invoked `colab new`.
354 + Without this, the child inherits Typer's defaults (`--auth=oauth2`,
355 + `--config=~/.config/colab-cli/sessions.json`), which causes:
356 + (a) wrong auth backend, and
357 + (b) the daemon's `state.store.get(session_name)` check finds nothing
358 + and exits with `reason=session_not_found` when the parent used
359 + `--config` to write to a non-default path.
360 + """
361 + cmd = [sys.executable, "-m", "colab_cli.cli"]
362 + if auth_provider is not None:
363 + cmd.append(f"--auth={auth_provider.value}")
364 + if config_path is not None:
365 + cmd.extend(["--config", config_path])
366 + cmd.extend(["keep-alive", endpoint, session_name])
367 + # Detach process
368 + kwargs = {}
369 + if sys.platform != "win32":
370 + kwargs["start_new_session"] = True
371 + else:
372 + # https://stackoverflow.com/questions/1356540/how-can-i-make-a-python-script-run-in-the-background-as-a-service-on-windows
373 + CREATE_NEW_PROCESS_GROUP = 0x00000200
374 + DETACHED_PROCESS = 0x00000008
375 + kwargs["creationflags"] = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
376 +
377 + p = subprocess.Popen(
378 + cmd,
379 + stdout=subprocess.DEVNULL,
380 + stderr=subprocess.DEVNULL,
381 + stdin=subprocess.DEVNULL,
382 + **kwargs,
383 + )
384 + return p.pid
385 +
386 +
387 +def keep_alive(
388 + endpoint: Annotated[str, typer.Argument(help="Endpoint ID")],
389 + session_name: Annotated[str, typer.Argument(help="Session name")],
390 +):
391 + """Hidden command to run keep-alive loop. Terminate after 24h."""
392 + from colab_cli.common import state
393 +
394 + state.history.log_event(
395 + session_name,
396 + "keep_alive_started",
397 + {"endpoint": endpoint, "pid": os.getpid()},
398 + )
399 +
400 + start_time = time.time()
401 + # 24 hours limit
402 + max_duration = 24 * 3600
403 + consecutive_4xx = 0
404 + iterations = 0
405 + last_error: Optional[Dict[str, Any]] = None
406 +
407 + reason = "time_limit_reached"
408 + extra: Dict[str, Any] = {}
409 + while time.time() - start_time < max_duration:
410 + iterations += 1
411 + # Check if session still exists in local state
412 + s = state.store.get(session_name)
413 + if not s:
414 + reason = "session_not_found"
415 + break
416 + if s.endpoint != endpoint:
417 + reason = "endpoint_mismatch"
418 + extra["expected_endpoint"] = endpoint
419 + extra["actual_endpoint"] = s.endpoint
420 + break
421 +
422 + try:
423 + state.client.keep_alive_assignment(endpoint)
424 + consecutive_4xx = 0
425 + last_error = None
426 + except Exception as e:
427 + code = get_status_code(e)
428 + response_body = getattr(e, "response_body", None)
429 + err_info = {
430 + "status_code": code,
431 + "error_type": type(e).__name__,
432 + "error": str(e)[:500],
433 + "response_body": (str(response_body)[:1000] if response_body else None),
434 + }
435 + last_error = err_info
436 + state.history.log_event(
437 + session_name,
438 + "keep_alive_error",
439 + {
440 + **err_info,
441 + "iteration": iterations,
442 + "consecutive_4xx": consecutive_4xx
443 + + (1 if code is not None and 400 <= code < 500 else 0),
444 + },
445 + )
446 + if code is not None and 400 <= code < 500:
447 + consecutive_4xx += 1
448 + if consecutive_4xx >= 2:
449 + reason = "consecutive_4xx_errors"
450 + break
451 + else:
452 + # For other errors (network), we retry and don't count as 4xx
453 + pass
454 +
455 + time.sleep(60)
456 +
457 + payload: Dict[str, Any] = {
458 + "reason": reason,
459 + "iterations": iterations,
460 + "duration_seconds": round(time.time() - start_time, 2),
461 + }
462 + if last_error is not None:
463 + payload["last_error"] = last_error
464 + payload.update(extra)
465 + state.history.log_event(session_name, "keep_alive_stopped", payload)
466 +
467 +
468 +def register(app: typer.Typer):
469 + app.command()(new)
470 + app.command(name="sessions")(sessions_command)
471 + app.command()(status)
472 + app.command()(stop)
473 + app.command(hidden=True)(keep_alive)
src/colab_cli/commands/utility.py new
+365
@@ -0,0 +1,365 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import platform
16 +from typing import Optional
17 +
18 +import typer
19 +from typing_extensions import Annotated
20 +
21 +from colab_cli import auto_update
22 +from colab_cli.auto_update import get_app_version
23 +from colab_cli.common import state
24 +
25 +
26 +def pay():
27 + """Open the Colab signup page to manage compute units"""
28 + import webbrowser
29 +
30 + url = "https://colab.research.google.com/signup"
31 + typer.echo(f"[colab] Opening {url}...")
32 + webbrowser.open(url)
33 +
34 +
35 +def url(
36 + session: Annotated[
37 + Optional[str], typer.Option("-s", "--session", help="Session name")
38 + ] = None,
39 + host: Annotated[
40 + str,
41 + typer.Option(
42 + "--host",
43 + help=(
44 + "Colab frontend host (origin) to use for the URL. The Colab "
45 + "frontend resolves `dbu` against `window.location.origin`, "
46 + "so this only changes the page origin, not the embedded "
47 + "backend path."
48 + ),
49 + ),
50 + ] = "https://colab.research.google.com",
51 + open_browser: Annotated[
52 + bool,
53 + typer.Option(
54 + "--open",
55 + help=(
56 + "After printing the URL, also open it in the system browser. "
57 + "Off by default so the command remains pipeable "
58 + "(e.g. `colab url -s s1 | xclip`)."
59 + ),
60 + ),
61 + ] = False,
62 +):
63 + """Print a browser URL that connects to an existing session.
64 +
65 + Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``,
66 + where the path is ``/tun/m/<endpoint>``. When opened, the Colab frontend
67 + skips ``/tun/m/assign`` and attaches the kernel to our existing VM.
68 +
69 + The ``dbu`` query parameter is the ``datalab_backend_url`` development
70 + flag. Because it's a development flag, URL-overriding it may be gated
71 + by the Colab frontend; some users may need to use the hash-based
72 + ``#datalabBackendUrl=...`` form instead.
73 + """
74 + # Imported here (not at module top) to mirror the lazy-state pattern used
75 + # elsewhere in this module and avoid a circular import via colab_cli.common.
76 + from urllib.parse import quote
77 +
78 + from colab_cli.common import state
79 +
80 + name = state.resolve_session(session)
81 + s = state.store.get(name)
82 + if not s:
83 + typer.echo(f"[colab] Session '{name}' not found.", err=True)
84 + raise typer.Exit(code=1)
85 +
86 + # Strip a trailing slash so we don't produce `https://host//notebooks/...`.
87 + host_clean = host.rstrip("/")
88 + # `dbu` value is the path `/tun/m/<endpoint>`. URL-encode it (incl. the
89 + # slashes via `safe=""`) so the value survives any downstream non-strict
90 + # query-string re-parsing — this is also the form shown in real Colab
91 + # connect URLs in the wild.
92 + dbu_value = quote(f"/tun/m/{s.endpoint}", safe="")
93 + connect_url = f"{host_clean}/notebooks/empty.ipynb?dbu={dbu_value}"
94 +
95 + # Print the URL on its own line with no `[colab]` prefix so the output
96 + # is pipeable (`colab url -s s1 | xclip`, etc.).
97 + typer.echo(connect_url)
98 +
99 + if open_browser:
100 + import webbrowser
101 +
102 + webbrowser.open(connect_url)
103 +
104 +
105 +def log(
106 + session: Annotated[
107 + Optional[str],
108 + typer.Option(
109 + "-s",
110 + "--session",
111 + help="Session name (if omitted, lists all sessions with logs)",
112 + ),
113 + ] = None,
114 + lines: Annotated[
115 + Optional[int],
116 + typer.Option(
117 + "-n", "--lines", help="Number of lines to show/export (default: all)"
118 + ),
119 + ] = None,
120 + type: Annotated[
121 + Optional[str],
122 + typer.Option(
123 + "-t",
124 + "--type",
125 + help="Filter by event type (e.g., execution, file_operation)",
126 + ),
127 + ] = None,
128 + output: Annotated[
129 + Optional[str],
130 + typer.Option(
131 + "-o",
132 + "--output",
133 + help="Output file path (suffix determines format: .ipynb, .md, .txt, .jsonl)",
134 + ),
135 + ] = None,
136 +):
137 + """Manage and view session history logs"""
138 + if not session:
139 + sessions_with_logs = state.history.list_sessions()
140 + if not sessions_with_logs:
141 + typer.echo("[colab] No session history found.")
142 + else:
143 + typer.echo("[colab] Sessions with history logs:")
144 + for n in sorted(sessions_with_logs):
145 + typer.echo(f" {n}")
146 + return
147 +
148 + events = state.history.get_history(session)
149 + if not events:
150 + typer.echo(f"[colab] No history found for session '{session}'.")
151 + return
152 +
153 + if type:
154 + events = [e for e in events if e.get("event_type") == type]
155 +
156 + if lines:
157 + events = events[-lines:]
158 +
159 + if output:
160 + from colab_cli.converter import export_history
161 +
162 + export_history(events, session, output)
163 + else:
164 + for event in events:
165 + ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
166 + etype = event.get("event_type", "unknown")
167 +
168 + if etype == "execution":
169 + preview = event.get("code", "").strip().split("\n")[0][:60]
170 + typer.echo(f"[{ts}] EXEC: {preview}...")
171 + elif etype == "file_operation":
172 + typer.echo(
173 + f"[{ts}] FILE: {event.get('op')} {event.get('path', event.get('remote', ''))}"
174 + )
175 + elif etype == "automation":
176 + typer.echo(f"[{ts}] AUTO: {event.get('op')}")
177 + elif etype == "stdin_request":
178 + typer.echo(f"[{ts}] INPT: {event.get('prompt', '').strip()}")
179 + elif etype == "input_reply":
180 + typer.echo(f"[{ts}] RPLY: {event.get('value', '').strip()}")
181 + elif etype == "keep_alive_started":
182 + typer.echo(
183 + f"[{ts}] KEEP: started endpoint={event.get('endpoint')} pid={event.get('pid')}"
184 + )
185 + elif etype == "keep_alive_error":
186 + msg = (
187 + f"[{ts}] KEEP: error iter={event.get('iteration')} "
188 + f"status={event.get('status_code')} "
189 + f"type={event.get('error_type')} "
190 + f"msg={event.get('error', '')[:120]}"
191 + )
192 + body = event.get("response_body")
193 + if body:
194 + msg += f" body={body[:300]}"
195 + typer.echo(msg)
196 + elif etype == "keep_alive_stopped":
197 + msg = (
198 + f"[{ts}] KEEP: stopped reason={event.get('reason')} "
199 + f"iters={event.get('iterations')} "
200 + f"duration={event.get('duration_seconds')}s"
201 + )
202 + last_err = event.get("last_error")
203 + if last_err:
204 + msg += (
205 + f" last_error=[status={last_err.get('status_code')} "
206 + f"type={last_err.get('error_type')} "
207 + f"msg={str(last_err.get('error', ''))[:120]}]"
208 + )
209 + if event.get("expected_endpoint") or event.get("actual_endpoint"):
210 + msg += (
211 + f" expected={event.get('expected_endpoint')} "
212 + f"actual={event.get('actual_endpoint')}"
213 + )
214 + typer.echo(msg)
215 + else:
216 + typer.echo(f"[{ts}] EVENT: {etype}")
217 +
218 +
219 +def whoami():
220 + """[debug] Print the active credentials' identity, scopes, and expiry.
221 +
222 + Mints an access token using the same path the rest of the CLI uses
223 + (`auth.get_credentials(...)` honoring the global `--auth=...` flag),
224 + then queries Google's tokeninfo endpoint and prints a human-readable
225 + summary. Useful when debugging "why is my call to
226 + colab.pa.googleapis.com 403-ing" — the answer is almost always a
227 + missing scope or a token whose `email` doesn't match what you
228 + expected.
229 +
230 + Hidden from `colab --help` because end users shouldn't need it; reach
231 + it via `colab whoami --help` or by knowing the name.
232 + """
233 + import json
234 + import urllib.error
235 + import urllib.parse
236 + import urllib.request
237 +
238 + from colab_cli.auth import get_credentials
239 +
240 + provider = state.auth_provider
241 +
242 + # Mint a fresh token. Some credential types (service-account, GCE, some
243 + # impersonated creds) don't populate `.token` until refresh() is called,
244 + # so we always refresh — cheap, ~1 RPC, and avoids a confusing
245 + # `creds.token is None` failure mode for valid credentials.
246 + sess = get_credentials(state.client_oauth_config, provider=provider)
247 + creds = sess.credentials
248 + try:
249 + from google.auth.transport.requests import Request as _GoogleAuthRequest
250 +
251 + creds.refresh(_GoogleAuthRequest())
252 + except Exception as e:
253 + typer.echo(f"[colab] whoami: failed to refresh credentials: {e}", err=True)
254 + raise typer.Exit(code=1)
255 +
256 + token = creds.token
257 + if not token:
258 + typer.echo(
259 + "[colab] whoami: credentials have no access token after refresh; "
260 + "the auth provider may have failed silently.",
261 + err=True,
262 + )
263 + raise typer.Exit(code=1)
264 +
265 + # Hit Google's tokeninfo endpoint. We use stdlib urllib (rather than the
266 + # already-authorized `sess`) deliberately: tokeninfo accepts the token as
267 + # a query parameter and does NOT want a Bearer header alongside it.
268 + qs = urllib.parse.urlencode({"access_token": token})
269 + url = f"https://oauth2.googleapis.com/tokeninfo?{qs}"
270 + try:
271 + with urllib.request.urlopen(url, timeout=10) as resp:
272 + body = resp.read().decode("utf-8")
273 + info = json.loads(body)
274 + except urllib.error.HTTPError as e:
275 + # tokeninfo returns 400 for invalid/expired/revoked tokens with a
276 + # JSON body like {"error":"invalid_token","error_description":"..."}.
277 + # Surface that body so the developer can see *why* it was rejected.
278 + try:
279 + err_body = e.read().decode("utf-8")
280 + except Exception:
281 + err_body = ""
282 + typer.echo(
283 + f"[colab] whoami: tokeninfo returned HTTP {e.code}: {err_body or e.reason}",
284 + err=True,
285 + )
286 + raise typer.Exit(code=1)
287 + except Exception as e:
288 + typer.echo(f"[colab] whoami: tokeninfo request failed: {e}", err=True)
289 + raise typer.Exit(code=1)
290 +
291 + # Format. Provider name from the AuthProvider enum (e.g. "adc"); email
292 + # may be missing for tokens scoped without `userinfo.email`, in which
293 + # case we say so explicitly rather than printing "Email: None".
294 + email = info.get("email") or "<unavailable: token has no userinfo.email scope>"
295 + expires_in = info.get("expires_in")
296 + try:
297 + expires_min = int(expires_in) // 60
298 + expires_str = f"{expires_min}m"
299 + except (TypeError, ValueError):
300 + expires_str = str(expires_in) if expires_in else "<unknown>"
301 +
302 + audience = info.get("audience") or info.get("aud") or "<none>"
303 + scopes = (info.get("scope") or "").split()
304 +
305 + typer.echo(f"Auth provider: {provider.value}")
306 + typer.echo(f"Email: {email}")
307 + typer.echo(f"Audience: {audience}")
308 + typer.echo(f"Expires in: {expires_str}")
309 + if scopes:
310 + typer.echo("Scopes:")
311 + for s in sorted(scopes):
312 + typer.echo(f" - {s}")
313 + else:
314 + typer.echo("Scopes: <none>")
315 +
316 +
317 +def version_command():
318 + """Show the version of the Colab CLI"""
319 + typer.echo(f"Version: {get_app_version()}")
320 +
321 +
322 +def update_command(
323 + install: Annotated[
324 + bool,
325 + typer.Option(
326 + "--install",
327 + help=(
328 + "After checking, run 'pip install -U google-colab-cli' to "
329 + "upgrade the CLI in place. No-op if already up to date. "
330 + "Linux only."
331 + ),
332 + ),
333 + ] = False,
334 +):
335 + """Check for latest version and print if an update is available"""
336 + auto_update.check_for_updates(quiet=False)
337 + if not install:
338 + return
339 +
340 + if platform.system() != "Linux":
341 + typer.echo(
342 + "[colab] '--install' self-install is only supported on Linux.", err=True
343 + )
344 + raise typer.Exit(code=1)
345 +
346 + # Skip the install when the current version already matches (or exceeds)
347 + # the latest known version, to avoid an unnecessary pip subprocess.
348 + settings = state.settings_store.load()
349 + if settings.latest_version and not auto_update._is_newer(
350 + settings.latest_version, auto_update.get_app_version()
351 + ):
352 + return
353 +
354 + auto_update.self_install()
355 +
356 +
357 +def register(app: typer.Typer):
358 + app.command()(pay)
359 + app.command()(log)
360 + app.command(name="url")(url)
361 + app.command(name="version")(version_command)
362 + app.command(name="update")(update_command)
363 + # Developer-only debugging aid; hidden from `colab --help` but still
364 + # reachable via `colab whoami` / `colab whoami --help`.
365 + app.command(name="whoami", hidden=True)(whoami)
src/colab_cli/common.py new
+185
@@ -0,0 +1,185 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import logging
16 +import os
17 +import signal
18 +import sys
19 +import time
20 +from typing import Optional
21 +
22 +import typer
23 +
24 +from colab_cli.auth import AuthProvider, get_credentials
25 +from colab_cli.client import Client, Prod
26 +from colab_cli.history import HistoryLogger
27 +from colab_cli.state import StateStore, SettingsStore
28 +
29 +
30 +class State:
31 + def __init__(self):
32 + self.client_oauth_config = os.path.expanduser("~/.colab-cli-oauth-config.json")
33 + self.config_path = None
34 + self.logtostderr = False
35 + self.auth_provider = AuthProvider.OAUTH2
36 + self._client = None
37 + self._store = None
38 + self._settings_store = None
39 + self._history = None
40 + self._sessions = None
41 +
42 + @property
43 + def store(self):
44 + if self._store is None:
45 + self._store = StateStore(self.config_path)
46 + return self._store
47 +
48 + @property
49 + def settings_store(self):
50 + if self._settings_store is None:
51 + # We don't currently allow overriding settings path via CLI,
52 + # but we could if needed. For now, use default.
53 + self._settings_store = SettingsStore()
54 + return self._settings_store
55 +
56 + @property
57 + def history(self):
58 + if self._history is None:
59 + self._history = HistoryLogger()
60 + return self._history
61 +
62 + @property
63 + def client(self):
64 + if self._client is None:
65 + creds = get_credentials(
66 + self.client_oauth_config, provider=self.auth_provider
67 + )
68 + self._client = Client(Prod(), creds)
69 + return self._client
70 +
71 + def prune_session(self, name: str):
72 + """Removes a session from local state and kills its keep-alive process."""
73 + s = self.store.get(name)
74 + if s and s.keep_alive_pid:
75 + kill_process(s.keep_alive_pid)
76 + self.store.remove(name)
77 + if self._sessions and name in self._sessions:
78 + del self._sessions[name]
79 + self.history.log_event(name, "session_terminated", {"reason": "pruned"})
80 +
81 + def sync_sessions(self):
82 + if self._sessions is not None:
83 + return self._sessions, self.client.list_assignments()
84 +
85 + # Check local store first. If it's empty, we don't necessarily need to hit the backend
86 + # unless we are specifically looking for server-side assignments (e.g. 'colab sessions').
87 + local_sessions = self.store.list()
88 + if not local_sessions:
89 + self._sessions = {}
90 + # We still need to return assignments for 'colab sessions' to work
91 + # But we only trigger client creation (and thus auth) if we have to.
92 + try:
93 + assignments = self.client.list_assignments()
94 + except SystemExit:
95 + # If auth fails, we just return empty assignments
96 + assignments = []
97 + return self._sessions, assignments
98 +
99 + assignments = self.client.list_assignments()
100 + active_endpoints = {a.endpoint for a in assignments}
101 +
102 + self._sessions = local_sessions
103 + pruned = 0
104 + for name, s in list(self._sessions.items()):
105 + if s.endpoint not in active_endpoints:
106 + self.prune_session(name)
107 + pruned += 1
108 +
109 + if pruned > 0:
110 + typer.echo(f"[colab] Pruned {pruned} stale local session(s).")
111 +
112 + return self._sessions, assignments
113 +
114 + def resolve_session(self, session_name: Optional[str]) -> str:
115 + if session_name:
116 + return session_name
117 +
118 + # Check local store first to avoid hitting the backend (and triggering auth) if we don't have to
119 + local_sessions = self.store.list()
120 + if not local_sessions:
121 + typer.echo(
122 + "[colab] Error: No active sessions found. Create one with 'colab new'."
123 + )
124 + raise typer.Exit(1)
125 +
126 + # If we have local sessions, we need to sync to make sure they are still valid.
127 + # This will trigger auth if valid credentials are not present.
128 + sessions, _ = self.sync_sessions()
129 + active_names = list(sessions.keys())
130 +
131 + if len(active_names) == 1:
132 + name = active_names[0]
133 + typer.echo(f"[colab] Using unique session '{name}'.")
134 + return name
135 + elif len(active_names) > 1:
136 + typer.echo(
137 + f"[colab] Error: Multiple active sessions found. Specify one with -s: {', '.join(active_names)}"
138 + )
139 + raise typer.Exit(1)
140 + else:
141 + typer.echo(
142 + "[colab] Error: No active sessions found. Create one with 'colab new'."
143 + )
144 + raise typer.Exit(1)
145 +
146 +
147 +state = State()
148 +
149 +
150 +def kill_process(pid: int):
151 + """Safely terminates a process by PID."""
152 + if not pid:
153 + return
154 + try:
155 + os.kill(pid, signal.SIGTERM)
156 + # Give it a moment to exit
157 + for _ in range(5):
158 + time.sleep(0.1)
159 + os.kill(pid, 0)
160 + except OSError:
161 + # Already dead
162 + pass
163 + except Exception:
164 + logging.debug(f"Failed to kill process {pid}")
165 +
166 +
167 +def setup_logging(log_to_stderr: bool):
168 + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
169 + logger = logging.getLogger()
170 + logger.setLevel(logging.DEBUG)
171 +
172 + requests_log = logging.getLogger("urllib3")
173 + requests_log.setLevel(logging.DEBUG)
174 + requests_log.propagate = True
175 +
176 + log_dir = os.path.expanduser("~/.config/colab-cli")
177 + os.makedirs(log_dir, exist_ok=True)
178 + file_handler = logging.FileHandler(os.path.join(log_dir, "colab.log"))
179 + file_handler.setFormatter(logging.Formatter(log_format))
180 + logger.addHandler(file_handler)
181 +
182 + if log_to_stderr:
183 + stream_handler = logging.StreamHandler(sys.stderr)
184 + stream_handler.setFormatter(logging.Formatter(log_format))
185 + logger.addHandler(stream_handler)
src/colab_cli/console.py new
+172
@@ -0,0 +1,172 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import json
16 +import logging
17 +import os
18 +import signal
19 +import sys
20 +import termios
21 +import threading
22 +import time
23 +import tty
24 +from urllib.parse import urlparse
25 +
26 +import websocket
27 +
28 +from colab_cli.state import SessionState
29 +
30 +logger = logging.getLogger(__name__)
31 +
32 +# Global flag to stop the read thread when the websocket closes
33 +_is_running = False
34 +_last_error = None
35 +
36 +# When stdin is piped and reaches EOF, we send "exit\n" to the remote shell and
37 +# then wait this many seconds for any remaining output (the shell's goodbye,
38 +# tmux teardown messages, etc.) to flush before closing the websocket from the
39 +# client side. Empirically 0.5s is enough for the typical /colab/tty backend
40 +# wrapped in tmux + bash; bumping it just delays exit, lowering it risks
41 +# truncating tail output.
42 +PIPED_EOF_GRACE_SECONDS = 0.5
43 +
44 +
45 +def on_message(ws, message):
46 + """Callback for when a message is received from the server."""
47 + try:
48 + data = json.loads(message)
49 + if "data" in data:
50 + # The backend sends raw ANSI escape sequences and string content.
51 + # We write it directly to stdout buffer to avoid python print() formatting.
52 + sys.stdout.buffer.write(data["data"].encode("utf-8"))
53 + sys.stdout.buffer.flush()
54 + except Exception as e:
55 + logger.debug(f"Error parsing message: {e}")
56 +
57 +
58 +def on_error(ws, error):
59 + """Callback for when a websocket error occurs."""
60 + global _last_error
61 + _last_error = error
62 + logger.error(f"WebSocket Error: {error}")
63 +
64 +
65 +def on_close(ws, close_status_code, close_msg):
66 + """Callback for when the websocket is closed."""
67 + global _is_running
68 + _is_running = False
69 +
70 +
71 +def send_terminal_size(ws):
72 + """Sends the current terminal size to the remote backend."""
73 + try:
74 + size = os.get_terminal_size()
75 + payload = json.dumps({"cols": size.columns, "rows": size.lines})
76 + ws.send(payload)
77 + except Exception as e:
78 + logger.debug(f"Failed to send terminal size: {e}")
79 +
80 +
81 +def on_open(ws):
82 + """Callback for when the websocket connection is opened."""
83 + global _is_running
84 + _is_running = True
85 +
86 + # Send initial terminal size
87 + send_terminal_size(ws)
88 +
89 + # Setup the background thread to read from stdin
90 + def read_stdin():
91 + is_tty = sys.stdin.isatty()
92 + while _is_running:
93 + try:
94 + # Read a single character (or escape sequence byte)
95 + char = sys.stdin.read(1)
96 + if not char:
97 + if not is_tty:
98 + # Piped input has reached EOF. The remote /colab/tty
99 + # endpoint wraps bash in tmux which intercepts \x04
100 + # (Ctrl-D) as a literal character, so it never exits.
101 + # Instead send "exit\n" so bash voluntarily terminates,
102 + # wait a short grace period for the shell's goodbye
103 + # output to drain back to us, then close the websocket
104 + # ourselves to guarantee the client unblocks.
105 + try:
106 + ws.send(json.dumps({"data": "exit\n"}))
107 + except Exception:
108 + pass
109 + time.sleep(PIPED_EOF_GRACE_SECONDS)
110 + try:
111 + ws.close()
112 + except Exception:
113 + pass
114 + break
115 + ws.send(json.dumps({"data": char}))
116 + except Exception:
117 + break
118 +
119 + thread = threading.Thread(target=read_stdin, daemon=True)
120 + thread.start()
121 +
122 +
123 +def connect_console(session: SessionState):
124 + """
125 + Connects to the Colab TTY endpoint and sets up a raw terminal session.
126 + """
127 + global _is_running, _last_error
128 + _last_error = None
129 +
130 + # Construct the WebSocket URL from the base URL
131 + parsed = urlparse(session.url)
132 + ws_scheme = "wss" if parsed.scheme == "https" else "ws"
133 + ws_url = f"{ws_scheme}://{parsed.netloc}/colab/tty?colab-runtime-proxy-token={session.token}"
134 +
135 + is_tty = sys.stdin.isatty()
136 + fd = sys.stdin.fileno() if is_tty else None
137 + old_settings = termios.tcgetattr(fd) if is_tty else None
138 +
139 + ws = websocket.WebSocketApp(
140 + url=ws_url,
141 + on_open=on_open,
142 + on_message=on_message,
143 + on_error=on_error,
144 + on_close=on_close,
145 + )
146 +
147 + def handle_sigwinch(signum, frame):
148 + """Handle window resize events."""
149 + if _is_running:
150 + send_terminal_size(ws)
151 +
152 + try:
153 + if is_tty:
154 + tty.setraw(fd, termios.TCSANOW)
155 + signal.signal(signal.SIGWINCH, handle_sigwinch)
156 +
157 + # This is a blocking call until the connection is closed
158 + ws.run_forever()
159 +
160 + if _last_error:
161 + # Re-raise or wrap terminal errors
162 + err_msg = str(_last_error)
163 + if "404" in err_msg or "401" in err_msg:
164 + # We raise a standard exception that the caller can recognize
165 + raise RuntimeError(f"Connection failed: {err_msg}")
166 + finally:
167 + if is_tty:
168 + # Always ensure the terminal is restored to its original state
169 + termios.tcsetattr(fd, termios.TCSANOW, old_settings)
170 + # Restore the default signal handler for resize
171 + signal.signal(signal.SIGWINCH, signal.SIG_DFL)
172 + print("\r\nConnection closed.")
src/colab_cli/contents.py new
+93
@@ -0,0 +1,93 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import base64
16 +from urllib.parse import quote
17 +
18 +import requests
19 +
20 +from colab_cli.state import SessionState
21 +from colab_cli.utils import get_status_code
22 +
23 +
24 +class ContentsClient:
25 + def __init__(self, session_state: SessionState):
26 + self.base_url = session_state.url.rstrip("/")
27 + self.token = session_state.token
28 +
29 + def _request(
30 + self, method: str, path: str, params: dict = None, json_data: dict = None
31 + ):
32 + # Quote the path, but don't encode slashes so directory paths stay intact
33 + quoted_path = quote(path.strip("/"), safe="/")
34 + url = f"{self.base_url}/api/contents/{quoted_path}"
35 +
36 + req_params = {"authuser": "0", "colab-runtime-proxy-token": self.token}
37 + if params:
38 + req_params.update(params)
39 +
40 + response = requests.request(method, url, params=req_params, json=json_data)
41 +
42 + if get_status_code(response) == 404:
43 + raise FileNotFoundError(f"File or directory not found: {path}")
44 +
45 + response.raise_for_status()
46 +
47 + # DELETE doesn't return JSON
48 + if method == "DELETE":
49 + return None
50 +
51 + return response.json()
52 +
53 + def list_dir(self, path: str):
54 + return self._request("GET", path)
55 +
56 + def upload(self, local_path: str, remote_path: str):
57 + with open(local_path, "rb") as f:
58 + content = f.read()
59 +
60 + b64_content = base64.b64encode(content).decode("ascii")
61 + filename = remote_path.split("/")[-1]
62 +
63 + payload = {
64 + "name": filename,
65 + "path": remote_path,
66 + "type": "file",
67 + "format": "base64",
68 + "content": b64_content,
69 + "chunk": 1,
70 + }
71 +
72 + return self._request("PUT", remote_path, json_data=payload)
73 +
74 + def download(self, remote_path: str, local_path: str):
75 + data = self._request("GET", remote_path, params={"content": "1"})
76 +
77 + if data.get("type") == "directory":
78 + raise IsADirectoryError(f"Cannot download a directory: {remote_path}")
79 +
80 + content = data.get("content", "")
81 + fmt = data.get("format")
82 +
83 + if fmt == "base64":
84 + content_bytes = base64.b64decode(content)
85 + else:
86 + # Assume text if it's not base64 explicitly encoded
87 + content_bytes = str(content).encode("utf-8")
88 +
89 + with open(local_path, "wb") as f:
90 + f.write(content_bytes)
91 +
92 + def rm(self, remote_path: str):
93 + self._request("DELETE", remote_path)
src/colab_cli/converter.py new
+184
@@ -0,0 +1,184 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import nbformat
16 +from typing import Any, Dict, List
17 +import json
18 +import os
19 +import uuid
20 +
21 +
22 +def export_history(events: List[Dict[str, Any]], session_name: str, output_path: str):
23 + """
24 + Exports history based on file extension.
25 + """
26 + ext = os.path.splitext(output_path)[1].lower()
27 +
28 + if ext == ".ipynb":
29 + nb = convert_history_to_ipynb(events, session_name)
30 + with open(output_path, "w", encoding="utf-8") as f:
31 + nbformat.write(nb, f)
32 +
33 + elif ext == ".jsonl":
34 + with open(output_path, "w", encoding="utf-8") as f:
35 + for event in events:
36 + f.write(json.dumps(event) + "\n")
37 +
38 + elif ext == ".md":
39 + with open(output_path, "w", encoding="utf-8") as f:
40 + f.write(f"# Colab Session: {session_name}\n\n")
41 + for event in events:
42 + ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
43 + etype = event.get("event_type")
44 + if etype == "execution":
45 + code = event.get("code", "")
46 + f.write(f"### Execution ({ts})\n```python\n{code}\n```\n\n")
47 + for o in event.get("outputs", []):
48 + if "text" in o:
49 + f.write(f"**Output**:\n```\n{o['text']}```\n\n")
50 + elif etype == "session_created":
51 + f.write(
52 + f"## Session Created: {ts}\n- Endpoint: `{event.get('endpoint')}`\n\n"
53 + )
54 + elif etype == "file_operation":
55 + f.write(
56 + f"*File Operation*: `{event.get('op')}` on `{event.get('path', event.get('remote', ''))}`\n\n"
57 + )
58 +
59 + elif ext == ".txt":
60 + with open(output_path, "w", encoding="utf-8") as f:
61 + f.write(f"Colab Session: {session_name}\n" + "=" * 20 + "\n\n")
62 + for event in events:
63 + ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
64 + etype = event.get("event_type", "unknown")
65 + f.write(f"[{ts}] {etype.upper()}: ")
66 + if etype == "execution":
67 + f.write(event.get("code", "").strip() + "\n")
68 + else:
69 + f.write(str(event) + "\n")
70 +
71 + else:
72 + print(f"[colab] Unsupported export format: {ext}")
73 + return
74 +
75 + print(f"[colab] Exported history to '{output_path}'.")
76 +
77 +
78 +def convert_history_to_ipynb(
79 + events: List[Dict[str, Any]], session_name: str
80 +) -> nbformat.NotebookNode:
81 + """
82 + Converts a list of session events to a Jupyter Notebook (v4).
83 + """
84 + nb = nbformat.v4.new_notebook()
85 + nb.metadata.kernelspec = {
86 + "display_name": "Python 3 (Google Colab)",
87 + "language": "python",
88 + "name": "python3",
89 + }
90 +
91 + title = f"# Colab Session: {session_name}\nGenerated from colab-cli history log."
92 + cell = nbformat.v4.new_markdown_cell(title)
93 + cell.id = str(uuid.uuid4())
94 + nb.cells.append(cell)
95 +
96 + for event in events:
97 + etype = event.get("event_type")
98 + ts = event.get("timestamp", "").split(".")[0].replace("T", " ")
99 +
100 + if etype == "session_created":
101 + meta = f"**Session Created**: {ts}\n- Endpoint: `{event.get('endpoint')}`\n- Hardware: `{event.get('accelerator')}`"
102 + cell = nbformat.v4.new_markdown_cell(meta)
103 + cell.id = str(uuid.uuid4())
104 + nb.cells.append(cell)
105 +
106 + elif etype == "execution":
107 + code = event.get("code", "")
108 + # Check for shell commands (starting with ! or from piped console)
109 + # If it's a raw shell command from a console pipe, wrap it in %%bash if it doesn't have !
110 + if event.get("source") == "piped" and not code.startswith("!"):
111 + code = "%%bash\n" + code
112 +
113 + outputs = _map_outputs(event.get("outputs", []))
114 + cell = nbformat.v4.new_code_cell(code, outputs=outputs)
115 + cell.id = str(uuid.uuid4())
116 + nb.cells.append(cell)
117 +
118 + elif etype == "automation":
119 + op = event.get("op")
120 + code = event.get("code", "")
121 + cell = nbformat.v4.new_markdown_cell(f"### Automation: {op} ({ts})")
122 + cell.id = str(uuid.uuid4())
123 + nb.cells.append(cell)
124 + if code:
125 + # Get the result from the next event if it's automation_result
126 + cell = nbformat.v4.new_code_cell(code)
127 + cell.id = str(uuid.uuid4())
128 + nb.cells.append(cell)
129 +
130 + elif etype == "automation_result":
131 + # We can attach these outputs to the previous automation cell if we were more clever,
132 + # but for now let's just ensure we capture the output.
133 + if event.get("outputs"):
134 + cell = nbformat.v4.new_code_cell(
135 + "# Result of previous automation",
136 + outputs=_map_outputs(event.get("outputs")),
137 + )
138 + cell.id = str(uuid.uuid4())
139 + nb.cells.append(cell)
140 +
141 + elif etype == "file_operation":
142 + cell = nbformat.v4.new_markdown_cell(
143 + f"*File Operation*: `{event.get('op')}` on `{event.get('path', event.get('remote', ''))}`"
144 + )
145 + cell.id = str(uuid.uuid4())
146 + nb.cells.append(cell)
147 +
148 + elif etype == "stdin_request":
149 + cell = nbformat.v4.new_markdown_cell(
150 + f"> **Input Requested**: {event.get('prompt')}"
151 + )
152 + cell.id = str(uuid.uuid4())
153 + nb.cells.append(cell)
154 +
155 + elif etype == "input_reply":
156 + cell = nbformat.v4.new_markdown_cell(
157 + f"> **User Input**: `{event.get('value')}`"
158 + )
159 + cell.id = str(uuid.uuid4())
160 + nb.cells.append(cell)
161 +
162 + return nb
163 +
164 +
165 +def _map_outputs(outputs: List[Dict[str, Any]]) -> List[nbformat.NotebookNode]:
166 + nb_outputs = []
167 + for o in outputs:
168 + otype = o.get("output_type")
169 + if "text" in o:
170 + nb_outputs.append(
171 + nbformat.v4.new_output("stream", name="stdout", text=o["text"])
172 + )
173 + elif "data" in o:
174 + nb_outputs.append(nbformat.v4.new_output("display_data", data=o["data"]))
175 + elif otype == "error":
176 + nb_outputs.append(
177 + nbformat.v4.new_output(
178 + "error",
179 + ename=o.get("ename", "Error"),
180 + evalue=o.get("evalue", ""),
181 + traceback=o.get("traceback", []),
182 + )
183 + )
184 + return nb_outputs
src/colab_cli/history.py new
+65
@@ -0,0 +1,65 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import datetime
16 +import json
17 +import os
18 +from typing import Any, Dict, List
19 +
20 +
21 +class HistoryLogger:
22 + def __init__(self, log_dir: str = "~/.config/colab-cli/history"):
23 + self.log_dir = os.path.expanduser(log_dir)
24 + os.makedirs(self.log_dir, exist_ok=True)
25 +
26 + def _get_log_path(self, session_name: str) -> str:
27 + return os.path.join(self.log_dir, f"{session_name}.jsonl")
28 +
29 + def log_event(self, session_name: str, event_type: str, data: Dict[str, Any]):
30 + """
31 + Appends a structured event to the session's history file.
32 +
33 + event_types:
34 + - session_created
35 + - session_terminated
36 + - execution (code + outputs)
37 + - input_requested (stdin prompts/replies)
38 + - file_operation (ls, rm, upload, download)
39 + - automation (auth, install, drivemount)
40 + """
41 + log_path = self._get_log_path(session_name)
42 + event = {
43 + "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
44 + "event_type": event_type,
45 + **data,
46 + }
47 + with open(log_path, "a", encoding="utf-8") as f:
48 + f.write(json.dumps(event) + "\n")
49 +
50 + def list_sessions(self) -> List[str]:
51 + if not os.path.exists(self.log_dir):
52 + return []
53 + return [f[:-6] for f in os.listdir(self.log_dir) if f.endswith(".jsonl")]
54 +
55 + def get_history(self, session_name: str) -> List[Dict[str, Any]]:
56 + log_path = self._get_log_path(session_name)
57 + if not os.path.exists(log_path):
58 + return []
59 +
60 + history = []
61 + with open(log_path, "r", encoding="utf-8") as f:
62 + for line in f:
63 + if line.strip():
64 + history.append(json.loads(line))
65 + return history
src/colab_cli/repl.py new
+173
@@ -0,0 +1,173 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import datetime
16 +from typing import Any, List, Optional
17 +
18 +from prompt_toolkit import PromptSession
19 +from prompt_toolkit.history import InMemoryHistory
20 +from prompt_toolkit.key_binding import KeyBindings
21 +from prompt_toolkit.lexers import PygmentsLexer
22 +from prompt_toolkit.styles import Style
23 +from pygments.lexers.python import PythonLexer
24 +from rich.console import Console
25 +from rich.text import Text
26 +
27 +from colab_cli.runtime import ColabRuntime
28 +from colab_cli.utils import handle_image
29 +
30 +console = Console()
31 +
32 +
33 +class ColabREPL:
34 + def __init__(
35 + self,
36 + runtime: ColabRuntime,
37 + session_name: Optional[str] = None,
38 + history_logger: Optional[Any] = None,
39 + output_image: Optional[str] = None,
40 + ):
41 + self.runtime = runtime
42 + self.session_name = session_name
43 + self.history_logger = history_logger
44 + self.output_image = output_image
45 + self.kb = KeyBindings()
46 + self.console = console
47 + self.repl_history: List[dict] = []
48 +
49 + @self.kb.add("enter")
50 + def _(event):
51 + event.current_buffer.validate_and_handle()
52 +
53 + @self.kb.add("escape", "enter")
54 + @self.kb.add("c-j")
55 + def _(event):
56 + event.current_buffer.insert_text("\n")
57 +
58 + self.session = PromptSession(
59 + history=InMemoryHistory(),
60 + lexer=PygmentsLexer(PythonLexer),
61 + include_default_pygments_style=False,
62 + key_bindings=self.kb,
63 + multiline=True,
64 + )
65 + self.style = Style.from_dict(
66 + {
67 + "prompt": "bold blue",
68 + "continuation": "#888888",
69 + }
70 + )
71 +
72 + def print_info(self, message: str):
73 + self.console.print(f"[bold blue][*][/bold blue] {message}")
74 +
75 + def print_error(self, message: str):
76 + self.console.print(f"[bold red][!][/bold red] {message}")
77 +
78 + def display_output(self, output: dict):
79 + if "text" in output:
80 + self.console.print(Text.from_ansi(output["text"]), end="")
81 + elif "data" in output:
82 + data = output["data"]
83 +
84 + # Check for images first
85 + image_displayed = False
86 + for mime_type in ["image/png", "image/jpeg"]:
87 + if mime_type in data:
88 + handle_image(
89 + data[mime_type], mime_type, target_path=self.output_image
90 + )
91 + image_displayed = True
92 + break
93 +
94 + if "text/plain" in data:
95 + text = data["text/plain"]
96 + # Skip generic IPython object reprs if we already showed an image
97 + if image_displayed and any(
98 + x in text for x in ["<IPython.core.display.Image", "<Figure size"]
99 + ):
100 + return
101 + self.console.print(Text.from_ansi(text))
102 + elif output.get("output_type") == "error":
103 + ename = output.get("ename", "Error")
104 + evalue = output.get("evalue", "")
105 + traceback = output.get("traceback", [])
106 + if traceback:
107 + self.console.print(Text.from_ansi("".join(traceback)))
108 + else:
109 + self.print_error(f"{ename}: {evalue}")
110 +
111 + def execute(self, code: str):
112 + if self.session_name:
113 + from colab_cli.common import state
114 +
115 + s = state.store.get(self.session_name)
116 + if s:
117 + s.last_execution = (
118 + "REPL",
119 + None,
120 + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
121 + )
122 + state.store.add(s)
123 +
124 + try:
125 + outputs = self.runtime.execute_code(
126 + code, output_hook=lambda o: self.display_output(o)
127 + )
128 + # Ensure next prompt starts on a newline after streaming
129 + print()
130 +
131 + self.repl_history.append({"input": code, "outputs": outputs or []})
132 + if self.history_logger and self.session_name:
133 + self.history_logger.log_event(
134 + self.session_name,
135 + "execution",
136 + {"code": code, "outputs": outputs or []},
137 + )
138 + except Exception as e:
139 + self.print_error(f"Execution failed: {e}")
140 +
141 + def run(self):
142 + self.console.print("Python 3 (Google Colab Runtime)\nType /quit to exit.")
143 +
144 + while True:
145 + try:
146 + result = self.session.prompt(
147 + ">>> ",
148 + style=self.style,
149 + )
150 +
151 + if result is None:
152 + continue
153 +
154 + code = result.strip()
155 +
156 + if not code:
157 + continue
158 +
159 + if code.lower() in ("/quit", "quit()", "exit()"):
160 + break
161 +
162 + self.execute(code)
163 +
164 + except EOFError:
165 + break
166 + except KeyboardInterrupt:
167 + print()
168 + continue
169 + except Exception as e:
170 + self.print_error(f"REPL Error: {e}")
171 +
172 + self.print_info("Goodbye!")
173 + self.runtime.stop()
src/colab_cli/runtime.py new
+243
@@ -0,0 +1,243 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import logging
16 +import time
17 +from typing import Any, Callable, Dict, List, Optional
18 +
19 +import jupyter_kernel_client
20 +import requests
21 +
22 +
23 +class ColabRuntime:
24 + def __init__(
25 + self,
26 + url: str,
27 + token: str,
28 + session_name: Optional[str] = None,
29 + history: Optional[Any] = None,
30 + kernel_id: Optional[str] = None,
31 + session_id: Optional[str] = None,
32 + on_kernel_started: Optional[Callable[[str], None]] = None,
33 + on_session_started: Optional[Callable[[str], None]] = None,
34 + ):
35 + self.url = url
36 + self.token = token
37 + self.session_name = session_name
38 + self.history = history
39 + self.kernel_id = kernel_id
40 + self.session_id = session_id
41 + self.on_kernel_started = on_kernel_started
42 + self.on_session_started = on_session_started
43 + self._kernel_client = None
44 + self.colab_request_hook: Optional[Callable[[Dict[str, Any], Any], None]] = None
45 +
46 + def _apply_ws_hook(self):
47 + wsclient = self._kernel_client._manager.client
48 + original_on_message = wsclient.kernel_socket.on_message
49 +
50 + def hooked_on_message(s_ws, message):
51 + if not self.colab_request_hook:
52 + return original_on_message(s_ws, message)
53 +
54 + try:
55 + from jupyter_kernel_client.wsclient import JupyterSubprotocol
56 +
57 + if wsclient._subprotocol == JupyterSubprotocol.DEFAULT:
58 + from jupyter_kernel_client.wsclient import (
59 + deserialize_msg_from_ws_default,
60 + )
61 +
62 + deserialize_msg = deserialize_msg_from_ws_default(message)
63 + elif wsclient._subprotocol == JupyterSubprotocol.V1:
64 + from jupyter_kernel_client.wsclient import (
65 + deserialize_msg_from_ws_v1,
66 + )
67 +
68 + channel, msg_list = deserialize_msg_from_ws_v1(message)
69 + deserialize_msg = wsclient.session.deserialize(msg_list)
70 + else:
71 + deserialize_msg = None
72 +
73 + if deserialize_msg:
74 + msg_type = deserialize_msg.get("msg_type")
75 + if msg_type == "colab_request":
76 + # We pass the deserialized msg and the wsclient to the hook
77 + if self.colab_request_hook(deserialize_msg, wsclient):
78 + # If the hook returns True, we intercept and do NOT pass to original
79 + return
80 +
81 + except Exception as e:
82 + logging.debug(f"Error in colab_request hook: {e}")
83 +
84 + # Call original for all other messages
85 + original_on_message(s_ws, message)
86 +
87 + wsclient.kernel_socket.on_message = hooked_on_message
88 +
89 + @property
90 + def kernel_client(self):
91 + if not self._kernel_client:
92 + retries = 3
93 + backoff = 2
94 + last_err = None
95 +
96 + for i in range(retries):
97 + try:
98 + client_kwargs = {
99 + "subprotocol": jupyter_kernel_client.JupyterSubprotocol.DEFAULT,
100 + "extra_params": {"colab-runtime-proxy-token": self.token},
101 + }
102 + if self.session_id:
103 + # WSSession (Session) expects 'session' for the ID
104 + client_kwargs["session"] = self.session_id
105 +
106 + self._kernel_client = jupyter_kernel_client.KernelClient(
107 + server_url=self.url,
108 + token=self.token,
109 + kernel_id=self.kernel_id,
110 + client_kwargs=client_kwargs,
111 + headers={
112 + "X-Colab-Client-Agent": "colab-cli",
113 + "X-Colab-Runtime-Proxy-Token": self.token,
114 + },
115 + )
116 + # Force _own_kernel to False. This prevents jupyter-kernel-client
117 + # from automatically deleting the kernel when the client is closed or deleted.
118 + self._kernel_client._own_kernel = False
119 +
120 + self._kernel_client.start()
121 + self._apply_ws_hook()
122 +
123 + # Capture IDs if we started fresh
124 + if not self.kernel_id and self._kernel_client.id:
125 + self.kernel_id = self._kernel_client.id
126 + if self.on_kernel_started:
127 + self.on_kernel_started(self.kernel_id)
128 +
129 + if (
130 + not self.session_id
131 + and self._kernel_client._manager.client.session.session
132 + ):
133 + self.session_id = (
134 + self._kernel_client._manager.client.session.session
135 + )
136 + if self.on_session_started:
137 + self.on_session_started(self.session_id)
138 + break
139 + except (
140 + requests.exceptions.ReadTimeout,
141 + requests.exceptions.ConnectTimeout,
142 + ) as e:
143 + last_err = e
144 + if i < retries - 1:
145 + sleep_time = backoff ** (i + 1)
146 + logging.debug(
147 + f"Kernel startup timeout, retrying in {sleep_time}s..."
148 + f" ({i + 1}/{retries})"
149 + )
150 + time.sleep(sleep_time)
151 + else:
152 + raise last_err
153 + except Exception as e:
154 + raise e
155 +
156 + return self._kernel_client
157 +
158 + def execute_code(
159 + self,
160 + code: str,
161 + allow_stdin: bool = False,
162 + stdin_hook: Any = None,
163 + output_hook: Optional[Callable[[Dict[str, Any]], None]] = None,
164 + ) -> List[Dict[str, Any]]:
165 + kwargs = {"allow_stdin": allow_stdin}
166 +
167 + # Wrap stdin_hook to log inputs
168 + original_stdin_hook = stdin_hook
169 +
170 + def wrapped_stdin_hook(prompt):
171 + if self.history and self.session_name:
172 + self.history.log_event(
173 + self.session_name, "stdin_request", {"prompt": prompt}
174 + )
175 +
176 + res = original_stdin_hook(prompt) if original_stdin_hook else input(prompt)
177 +
178 + if self.history and self.session_name:
179 + self.history.log_event(self.session_name, "input_reply", {"value": res})
180 + return res
181 +
182 + if allow_stdin:
183 + kwargs["stdin_hook"] = wrapped_stdin_hook
184 +
185 + if output_hook:
186 + # If we have an output hook, we use execute_interactive and manage buffering ourselves
187 + outputs = []
188 +
189 + def wrapped_output_hook(msg):
190 + from jupyter_kernel_client.client import (
191 + output_hook as default_output_hook,
192 + )
193 +
194 + # Update local outputs list using the default logic
195 + new_indexes = default_output_hook(outputs, msg)
196 + # If new outputs were added, call our streaming hook with the new data
197 + if new_indexes:
198 + for idx in sorted(new_indexes):
199 + if idx < len(outputs):
200 + output_hook(outputs[idx])
201 +
202 + reply = self.kernel_client.execute_interactive(
203 + code, output_hook=wrapped_output_hook, **kwargs
204 + )
205 + # execute_interactive returns the raw reply message
206 + reply_content = reply["content"] if reply else {"status": "error"}
207 + else:
208 + reply = self.kernel_client.execute(code, **kwargs)
209 + if not reply:
210 + return []
211 + outputs = reply.get("outputs", [])
212 + reply_content = reply
213 +
214 + # If there's an error status but no error in outputs, synthesize one
215 + if reply_content.get("status") == "error":
216 + has_error_output = any(o.get("output_type") == "error" for o in outputs)
217 + if not has_error_output:
218 + outputs.append(
219 + {
220 + "output_type": "error",
221 + "ename": reply_content.get("ename", "Error"),
222 + "evalue": reply_content.get("evalue", "Unknown error"),
223 + "traceback": reply_content.get("traceback", []),
224 + }
225 + )
226 +
227 + return outputs
228 +
229 + def stop(self, shutdown_kernel: bool = False):
230 + if self._kernel_client:
231 + try:
232 + # We manage kernel lifecycle explicitly.
233 + # To prevent automatic shutdown, we bypass the manager's stop() and
234 + # directly close the channels and socket.
235 + client = self._kernel_client._manager.client
236 + client.stop_channels()
237 + if client.kernel_socket:
238 + client.kernel_socket.close()
239 +
240 + if shutdown_kernel:
241 + self._kernel_client._manager.shutdown_kernel(now=True)
242 + except Exception:
243 + logging.exception("Error stopping kernel client")
src/colab_cli/state.py new
+152
@@ -0,0 +1,152 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import contextlib
16 +import json
17 +import os
18 +import fcntl
19 +from datetime import datetime
20 +from typing import Dict, Optional, Tuple, Iterator, IO
21 +from pydantic import BaseModel
22 +
23 +
24 +class SessionState(BaseModel):
25 + name: str
26 + token: str
27 + url: str
28 + endpoint: str
29 + variant: str = "DEFAULT"
30 + accelerator: str = "NONE"
31 + kernel_id: Optional[str] = None
32 + session_id: Optional[str] = None
33 + last_execution: Optional[Tuple[str, Optional[str], str]] = None
34 + running: Optional[str] = None
35 + keep_alive_pid: Optional[int] = None
36 +
37 +
38 +class Settings(BaseModel):
39 + update_url: str = "https://pypi.org/pypi/google-colab-cli/json"
40 + last_check: Optional[datetime] = None
41 + enable_update_check: bool = True
42 + # Highest version seen on the update source; cached for the banner.
43 + latest_version: Optional[str] = None
44 +
45 +
46 +class _LockedFileStore:
47 + def __init__(self, path: str):
48 + self.path = path
49 + self._ensure_dir()
50 +
51 + def _ensure_dir(self):
52 + os.makedirs(os.path.dirname(self.path), exist_ok=True)
53 +
54 + def _write_data(self, f: IO, data: str):
55 + f.seek(0)
56 + f.truncate()
57 + f.write(data)
58 + f.flush()
59 + os.fsync(f.fileno())
60 +
61 + @contextlib.contextmanager
62 + def _lock_shared(self) -> Iterator[Optional[IO]]:
63 + if not os.path.exists(self.path):
64 + yield None
65 + return
66 + with open(self.path, "r") as f:
67 + fcntl.flock(f, fcntl.LOCK_SH)
68 + try:
69 + yield f
70 + finally:
71 + fcntl.flock(f, fcntl.LOCK_UN)
72 +
73 + @contextlib.contextmanager
74 + def _lock_exclusive(self) -> Iterator[IO]:
75 + with open(self.path, "a+") as f:
76 + fcntl.flock(f, fcntl.LOCK_EX)
77 + try:
78 + yield f
79 + finally:
80 + fcntl.flock(f, fcntl.LOCK_UN)
81 +
82 +
83 +class SettingsStore(_LockedFileStore):
84 + def __init__(self, path: Optional[str] = None):
85 + if not path:
86 + path = os.path.expanduser("~/.config/colab-cli/settings.json")
87 + super().__init__(path)
88 +
89 + def load(self) -> Settings:
90 + with self._lock_shared() as f:
91 + if f is None:
92 + return Settings()
93 + try:
94 + content = f.read()
95 + if not content or content.isspace():
96 + return Settings()
97 + data = json.loads(content)
98 + return Settings.model_validate(data)
99 + except Exception:
100 + return Settings()
101 +
102 + def save(self, settings: Settings):
103 + with self._lock_exclusive() as f:
104 + self._write_data(f, settings.model_dump_json(indent=2))
105 +
106 +
107 +class StateStore(_LockedFileStore):
108 + def __init__(self, path: Optional[str] = None):
109 + if not path:
110 + path = os.path.expanduser("~/.config/colab-cli/sessions.json")
111 + super().__init__(path)
112 +
113 + def _load_raw(self, f) -> Dict[str, SessionState]:
114 + try:
115 + f.seek(0)
116 + content = f.read()
117 + if not content or content.isspace():
118 + return {}
119 + data = json.loads(content)
120 + return {k: SessionState(**v) for k, v in data.items()}
121 + except Exception:
122 + return {}
123 +
124 + def _save_raw(self, f, sessions: Dict[str, SessionState]):
125 + content = json.dumps({k: v.model_dump() for k, v in sessions.items()}, indent=2)
126 + self._write_data(f, content)
127 +
128 + def add(self, state: SessionState):
129 + with self._lock_exclusive() as f:
130 + sessions = self._load_raw(f)
131 + sessions[state.name] = state
132 + self._save_raw(f, sessions)
133 +
134 + def get(self, name: str) -> Optional[SessionState]:
135 + with self._lock_shared() as f:
136 + if f is None:
137 + return None
138 + sessions = self._load_raw(f)
139 + return sessions.get(name)
140 +
141 + def remove(self, name: str):
142 + with self._lock_exclusive() as f:
143 + sessions = self._load_raw(f)
144 + if name in sessions:
145 + del sessions[name]
146 + self._save_raw(f, sessions)
147 +
148 + def list(self) -> Dict[str, SessionState]:
149 + with self._lock_shared() as f:
150 + if f is None:
151 + return {}
152 + return self._load_raw(f)
src/colab_cli/utils.py new
+85
@@ -0,0 +1,85 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import base64
16 +import logging
17 +import sys
18 +import tempfile
19 +
20 +
21 +from typing import Optional
22 +
23 +
24 +def get_status_code(e: Exception) -> Optional[int]:
25 + """Safely extracts status code from various exception types."""
26 + if hasattr(e, "response") and e.response is not None:
27 + if hasattr(e.response, "status_code"):
28 + return e.response.status_code
29 + if hasattr(e, "status_code"):
30 + return e.status_code
31 + return None
32 +
33 +
34 +def is_terminal_error(e: Exception) -> bool:
35 + """Checks if an exception indicates a lost session (404/401)."""
36 + code = get_status_code(e)
37 + if code in (404, 401):
38 + return True
39 + # Some exceptions from jupyter-kernel-client might wrap the real one or be different
40 + err_msg = str(e)
41 + if "404" in err_msg or "401" in err_msg:
42 + return True
43 + return False
44 +
45 +
46 +def print_kitty(image_bytes: bytes):
47 + """
48 + Outputs an image using the Kitty Graphics Protocol.
49 + Expects PNG bytes.
50 +
51 + No-op when stdout is not a TTY: the escape sequence is meaningless to a
52 + file/pipe and visually corrupts captured output (e.g. when piping
53 + `colab exec` into a shell tool, redirecting to a log file, or running
54 + under non-Kitty terminals). Callers still get the image via
55 + `handle_image`'s file-write path.
56 + """
57 + if not sys.stdout.isatty():
58 + return
59 + try:
60 + b64_data = base64.b64encode(image_bytes).decode("ascii")
61 + sys.stdout.write("\n\033_Ga=T,f=100;")
62 + sys.stdout.write(b64_data)
63 + sys.stdout.write("\033\\\n")
64 + sys.stdout.flush()
65 + except Exception:
66 + logging.exception("Kitty rendering failed")
67 +
68 +
69 +def handle_image(image_b64: str, mime_type: str = "image/png", target_path: str = None):
70 + image_bytes = base64.b64decode(image_b64)
71 + # Print inline using Kitty protocol
72 + print_kitty(image_bytes)
73 +
74 + if target_path:
75 + # If a target path is specified, save it there
76 + with open(target_path, "wb") as f:
77 + f.write(image_bytes)
78 + print(f"\n[Image saved to: {target_path}]")
79 + else:
80 + # Save to temp file as fallback
81 + ext = mime_type.split("/")[-1]
82 + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}")
83 + tmp.write(image_bytes)
84 + tmp.close()
85 + print(f"\n[Image saved to: {tmp.name}]")
tests/conftest.py new
+38
@@ -0,0 +1,38 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import pytest
16 +from unittest.mock import MagicMock
17 +
18 +
19 +@pytest.fixture(autouse=True)
20 +def mock_common_state(mocker):
21 + # Patch the state singleton in common.py
22 + mock_state = mocker.patch("colab_cli.common.state")
23 +
24 + # Setup standard mocks for properties
25 + mock_state.store = MagicMock()
26 + mock_state.client = MagicMock()
27 + mock_state.history = MagicMock()
28 +
29 + # Default behavior for sync_sessions
30 + mock_state.sync_sessions.return_value = ({}, [])
31 +
32 + # Global patch for ColabRuntime to prevent network calls
33 + # We patch it in the modules where it is imported and used
34 + mocker.patch("colab_cli.commands.session.ColabRuntime")
35 + mocker.patch("colab_cli.commands.execution.ColabRuntime")
36 + mocker.patch("colab_cli.commands.automation.ColabRuntime")
37 +
38 + return mock_state
tests/test_auth.py new
+107
@@ -0,0 +1,107 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock, mock_open, patch
16 +
17 +import pytest
18 +
19 +from colab_cli.auth import TOKEN_CONFIG_PATH, AuthProvider, get_credentials
20 +
21 +
22 +@pytest.fixture
23 +def mock_deps(mocker):
24 + m_exists = mocker.patch("os.path.exists")
25 + m_makedirs = mocker.patch("os.makedirs")
26 + m_creds_cls = mocker.patch("colab_cli.auth.Credentials")
27 + m_flow_cls = mocker.patch("colab_cli.auth.InstalledAppFlow")
28 + m_request = mocker.patch("colab_cli.auth.Request")
29 + m_session = mocker.patch("colab_cli.auth.requests.AuthorizedSession")
30 +
31 + # By default, pretend oauth config doesn't exist
32 + m_exists.return_value = False
33 +
34 + return {
35 + "exists": m_exists,
36 + "makedirs": m_makedirs,
37 + "creds_cls": m_creds_cls,
38 + "flow_cls": m_flow_cls,
39 + "request": m_request,
40 + "session": m_session,
41 + }
42 +
43 +
44 +def test_get_credentials_no_config(mock_deps):
45 + with pytest.raises(FileNotFoundError, match="Client OAuth config not found"):
46 + get_credentials("missing_config.json", provider=AuthProvider.OAUTH2)
47 +
48 +
49 +def test_get_credentials_valid_token(mock_deps):
50 + # Setup token exists
51 + def exists_side_effect(path):
52 + return path in ["dummy_config.json", TOKEN_CONFIG_PATH]
53 +
54 + mock_deps["exists"].side_effect = exists_side_effect
55 +
56 + # Valid creds
57 + mock_creds = MagicMock()
58 + mock_creds.valid = True
59 + mock_deps["creds_cls"].from_authorized_user_file.return_value = mock_creds
60 +
61 + # Mock open for config
62 + m_open = mock_open(read_data='{"web":{"client_id":"id"}}')
63 + with patch("builtins.open", m_open):
64 + res = get_credentials("dummy_config.json", provider=AuthProvider.OAUTH2)
65 +
66 + mock_deps["creds_cls"].from_authorized_user_file.assert_called_once()
67 + mock_deps["session"].assert_called_once_with(mock_creds)
68 + assert res == mock_deps["session"].return_value
69 +
70 +
71 +def test_get_credentials_expired_token_refresh(mock_deps):
72 + def exists_side_effect(path):
73 + return path in ["dummy_config.json", TOKEN_CONFIG_PATH]
74 +
75 + mock_deps["exists"].side_effect = exists_side_effect
76 +
77 + mock_creds = MagicMock()
78 + mock_creds.valid = False
79 + mock_creds.expired = True
80 + mock_creds.refresh_token = "some_token"
81 + mock_creds.to_json.return_value = '{"token":"refreshed"}'
82 + mock_deps["creds_cls"].from_authorized_user_file.return_value = mock_creds
83 +
84 + m_open = mock_open(read_data='{"web":{"client_id":"id"}}')
85 + with patch("builtins.open", m_open):
86 + res = get_credentials("dummy_config.json", provider=AuthProvider.OAUTH2)
87 +
88 + mock_creds.refresh.assert_called_once()
89 + m_open.assert_any_call(TOKEN_CONFIG_PATH, "w")
90 + assert res == mock_deps["session"].return_value
91 +
92 +
93 +def test_get_credentials_no_token(mock_deps):
94 + mock_deps["exists"].side_effect = lambda path: path == "dummy_config.json"
95 +
96 + mock_flow = MagicMock()
97 + mock_creds_new = MagicMock()
98 + mock_creds_new.to_json.return_value = '{"token":"new"}'
99 + mock_flow.run_local_server.return_value = mock_creds_new
100 + mock_deps["flow_cls"].from_client_config.return_value = mock_flow
101 +
102 + m_open = mock_open(read_data='{"web":{"client_id":"id"}}')
103 + with patch("builtins.open", m_open):
104 + get_credentials("dummy_config.json", provider=AuthProvider.OAUTH2)
105 +
106 + mock_deps["flow_cls"].from_client_config.assert_called_once()
107 + mock_flow.run_local_server.assert_called_once()
tests/test_auth_adc.py new
+127
@@ -0,0 +1,127 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Tests for the Application Default Credentials (ADC) auth provider."""
16 +
17 +from unittest.mock import MagicMock
18 +
19 +import pytest
20 +
21 +from colab_cli.auth import AuthProvider, get_credentials
22 +
23 +
24 +def test_get_credentials_adc_success(mocker):
25 + """`--auth=adc` should delegate to google.auth.default() and wrap the
26 + resulting credentials in an AuthorizedSession."""
27 + mock_creds = MagicMock()
28 + # Default ADC user creds don't need (or support) re-scoping; the scopes
29 + # are fixed at `gcloud auth application-default login` time.
30 + mock_creds.requires_scopes = False
31 + mock_default = mocker.patch(
32 + "google.auth.default", return_value=(mock_creds, "some-project-id")
33 + )
34 + mock_session_cls = mocker.patch("colab_cli.auth.requests.AuthorizedSession")
35 +
36 + res = get_credentials(provider=AuthProvider.ADC)
37 +
38 + mock_default.assert_called_once()
39 + mock_session_cls.assert_called_once_with(mock_creds)
40 + assert res == mock_session_cls.return_value
41 +
42 +
43 +def test_get_credentials_adc_default_error_propagates(mocker):
44 + """If google.auth.default() raises (e.g. no ADC configured), the error
45 + should propagate to the caller so they can run `gcloud auth
46 + application-default login`."""
47 + from google.auth.exceptions import DefaultCredentialsError
48 +
49 + mocker.patch(
50 + "google.auth.default",
51 + side_effect=DefaultCredentialsError("No ADC found"),
52 + )
53 + mocker.patch("colab_cli.auth.requests.AuthorizedSession")
54 +
55 + with pytest.raises(DefaultCredentialsError):
56 + get_credentials(provider=AuthProvider.ADC)
57 +
58 +
59 +def test_get_credentials_adc_does_not_invoke_other_providers(mocker):
60 + """ADC path must not kick off the InstalledAppFlow."""
61 + mock_creds = MagicMock()
62 + mock_creds.requires_scopes = False
63 + mocker.patch("google.auth.default", return_value=(mock_creds, None))
64 + mocker.patch("colab_cli.auth.requests.AuthorizedSession")
65 +
66 + mock_flow = mocker.patch("colab_cli.auth.InstalledAppFlow")
67 +
68 + get_credentials(provider=AuthProvider.ADC)
69 +
70 + mock_flow.from_client_config.assert_not_called()
71 +
72 +
73 +def test_get_credentials_adc_requests_colaboratory_scope(mocker):
74 + """The RuntimeService at colab.pa.googleapis.com requires the
75 + `colaboratory` scope. ADC must request it via google.auth.default().
76 + """
77 + mock_creds = MagicMock()
78 + # Pretend creds don't need re-scoping (e.g., user creds from gcloud).
79 + mock_creds.requires_scopes = False
80 + mock_default = mocker.patch(
81 + "google.auth.default", return_value=(mock_creds, "proj")
82 + )
83 + mocker.patch("colab_cli.auth.requests.AuthorizedSession")
84 +
85 + get_credentials(provider=AuthProvider.ADC)
86 +
87 + scopes = mock_default.call_args.kwargs.get("scopes")
88 + assert scopes is not None, "google.auth.default() must be called with scopes="
89 + assert "https://www.googleapis.com/auth/colaboratory" in scopes
90 + assert "https://www.googleapis.com/auth/userinfo.email" in scopes
91 +
92 +
93 +def test_get_credentials_adc_reapplies_scopes_for_scopable_creds(mocker):
94 + """For credential subclasses that support `with_scopes` (service accounts,
95 + GCE/GKE, etc.), we must call it so the colaboratory scope sticks even if
96 + google.auth.default() ignored the kwarg.
97 + """
98 + rescoped = MagicMock(name="rescoped_creds")
99 + mock_creds = MagicMock()
100 + mock_creds.requires_scopes = True
101 + mock_creds.with_scopes.return_value = rescoped
102 + mocker.patch("google.auth.default", return_value=(mock_creds, "proj"))
103 + mock_session_cls = mocker.patch("colab_cli.auth.requests.AuthorizedSession")
104 +
105 + get_credentials(provider=AuthProvider.ADC)
106 +
107 + mock_creds.with_scopes.assert_called_once()
108 + applied_scopes = mock_creds.with_scopes.call_args.args[0]
109 + assert "https://www.googleapis.com/auth/colaboratory" in applied_scopes
110 + # The session is built from the *rescoped* creds, not the original.
111 + mock_session_cls.assert_called_once_with(rescoped)
112 +
113 +
114 +def test_get_credentials_adc_tolerates_with_scopes_failure(mocker):
115 + """User creds (from `gcloud auth application-default login`) raise
116 + NotImplementedError on with_scopes. We must fall back gracefully."""
117 + mock_creds = MagicMock()
118 + mock_creds.requires_scopes = True
119 + mock_creds.with_scopes.side_effect = NotImplementedError("user creds")
120 + mocker.patch("google.auth.default", return_value=(mock_creds, "proj"))
121 + mock_session_cls = mocker.patch("colab_cli.auth.requests.AuthorizedSession")
122 +
123 + # Should not raise.
124 + get_credentials(provider=AuthProvider.ADC)
125 +
126 + # Falls back to using the original (un-rescoped) creds.
127 + mock_session_cls.assert_called_once_with(mock_creds)
tests/test_automation.py new
+100
@@ -0,0 +1,100 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import patch
16 +import pytest
17 +from typer.testing import CliRunner
18 +from colab_cli.cli import app
19 +from colab_cli.state import SessionState
20 +
21 +runner = CliRunner()
22 +
23 +
24 +@pytest.fixture
25 +def mock_session():
26 + return SessionState(
27 + name="test-session",
28 + token="test-token",
29 + url="https://test.url",
30 + endpoint="e1",
31 + )
32 +
33 +
34 +@patch("colab_cli.commands.automation.ColabRuntime")
35 +@patch("colab_cli.common.state")
36 +def test_cli_auth(mock_state, mock_runtime_class, mock_session):
37 + mock_state.store.get.return_value = mock_session
38 + mock_state.resolve_session.return_value = "test-session"
39 +
40 + mock_runtime = mock_runtime_class.return_value
41 + mock_runtime.execute_code.return_value = [{"text": "Success"}]
42 +
43 + result = runner.invoke(app, ["auth", "-s", "test-session"])
44 + assert result.exit_code == 0
45 +
46 + assert mock_session.last_execution[0] == "automation:auth"
47 + assert mock_session.last_execution[1] is None
48 + assert mock_session.last_execution[2] is not None
49 + mock_state.store.add.assert_called_with(mock_session)
50 +
51 + # Verify ColabRuntime was invoked with the correct code
52 + mock_runtime.execute_code.assert_called_once()
53 + called_code = mock_runtime.execute_code.call_args[0][0]
54 +
55 + assert "os.environ['USE_AUTH_EPHEM'] = '0'" in called_code
56 + assert "auth.authenticate_user()" in called_code
57 +
58 +
59 +@patch("colab_cli.commands.automation.ColabRuntime")
60 +@patch("colab_cli.common.state")
61 +def test_cli_install(mock_state, mock_runtime_class, mock_session):
62 + mock_state.store.get.return_value = mock_session
63 + mock_state.resolve_session.return_value = "test-session"
64 +
65 + mock_runtime = mock_runtime_class.return_value
66 + mock_runtime.execute_code.return_value = [{"text": "Installed"}]
67 +
68 + result = runner.invoke(app, ["install", "-s", "test-session", "pandas", "numpy"])
69 + assert result.exit_code == 0
70 + assert mock_session.last_execution[0] == "automation:install"
71 + assert mock_session.last_execution[2] is not None
72 + mock_state.store.add.assert_called_with(mock_session)
73 +
74 + mock_runtime.execute_code.assert_called_once()
75 + called_code = mock_runtime.execute_code.call_args[0][0]
76 +
77 + assert "subprocess" in called_code
78 + assert "pip" in called_code
79 + assert "pandas" in called_code
80 + assert "numpy" in called_code
81 +
82 +
83 +@patch("colab_cli.commands.automation.ColabRuntime")
84 +@patch("colab_cli.common.state")
85 +def test_cli_drivemount(mock_state, mock_runtime_class, mock_session):
86 + mock_state.store.get.return_value = mock_session
87 + mock_state.resolve_session.return_value = "test-session"
88 +
89 + mock_runtime = mock_runtime_class.return_value
90 + mock_runtime.execute_code.return_value = [{"text": "Mounted"}]
91 +
92 + result = runner.invoke(app, ["drivemount", "-s", "test-session", "/foo/bar"])
93 + assert result.exit_code == 0
94 +
95 + # Verify ColabRuntime was invoked with the correct code
96 + mock_runtime.execute_code.assert_called_once()
97 + called_code = mock_runtime.execute_code.call_args[0][0]
98 +
99 + assert "drive.mount('/foo/bar')" in called_code
100 + assert mock_runtime.colab_request_hook is not None
tests/test_cli.py new
+565
@@ -0,0 +1,565 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import time
16 +from unittest.mock import MagicMock, patch
17 +
18 +import pytest
19 +from typer.testing import CliRunner
20 +
21 +from colab_cli.cli import app
22 +from colab_cli.client import (
23 + Assignment,
24 + ColabRequestError,
25 + PostAssignmentResponse,
26 +)
27 +
28 +runner = CliRunner()
29 +
30 +
31 +@pytest.fixture
32 +def mock_client(mock_common_state):
33 + return mock_common_state.client
34 +
35 +
36 +@pytest.fixture
37 +def mock_store(mock_common_state):
38 + return mock_common_state.store
39 +
40 +
41 +@pytest.fixture
42 +def mock_history(mock_common_state):
43 + return mock_common_state.history
44 +
45 +
46 +def test_cli_new_tpu(mock_client, mock_store):
47 + mock_res = MagicMock()
48 + mock_res.__class__ = PostAssignmentResponse
49 + mock_res.runtime_proxy_info.token = "t1"
50 + mock_res.runtime_proxy_info.url = "u1"
51 + mock_res.endpoint = "e1"
52 + mock_client.assign.return_value = mock_res
53 +
54 + result = runner.invoke(app, ["new", "-s", "my-session", "--tpu", "v5e1"])
55 + assert result.exit_code == 0
56 +
57 + added_state = mock_store.add.call_args[0][0]
58 + assert added_state.name == "my-session"
59 + assert added_state.variant == "TPU"
60 + assert added_state.accelerator == "V5E1"
61 +
62 +
63 +def test_cli_new_gpu(mock_client, mock_store):
64 + mock_res = MagicMock()
65 + mock_res.__class__ = Assignment
66 + mock_res.runtime_proxy_token = "t2"
67 + mock_res.endpoint = "e2"
68 + del mock_res.runtime_proxy_info
69 + mock_client.assign.return_value = mock_res
70 +
71 + result = runner.invoke(app, ["new", "-s", "gpu-sess", "--gpu", "A100"])
72 + assert result.exit_code == 0
73 +
74 + added_state = mock_store.add.call_args[0][0]
75 + assert added_state.name == "gpu-sess"
76 + assert added_state.variant == "GPU"
77 + assert added_state.accelerator == "A100"
78 + assert added_state.token == "t2"
79 +
80 +
81 +@pytest.mark.parametrize(
82 + "gpu_flag,expected_acc",
83 + [
84 + ("H100", "H100"),
85 + ("l4", "L4"),
86 + ("t4", "T4"),
87 + ("g4", "G4"),
88 + ],
89 +)
90 +def test_cli_new_gpu_variants(mock_client, mock_store, gpu_flag, expected_acc):
91 + mock_res = MagicMock()
92 + mock_res.__class__ = PostAssignmentResponse
93 + mock_res.runtime_proxy_info.token = "t1"
94 + mock_res.runtime_proxy_info.url = "u1"
95 + mock_res.endpoint = "e1"
96 + mock_client.assign.return_value = mock_res
97 +
98 + result = runner.invoke(app, ["new", "-s", "s", "--gpu", gpu_flag])
99 + assert result.exit_code == 0
100 +
101 + added_state = mock_store.add.call_args[0][0]
102 + assert added_state.accelerator == expected_acc
103 +
104 +
105 +def test_cli_sessions_unified_format(mock_client, mock_common_state):
106 + """`sessions` should lead each line with the local name when known:
107 + `[name] endpoint | Hardware: X | Variant: Y`.
108 + """
109 + mock_assignment = MagicMock()
110 + mock_assignment.endpoint = "e1"
111 + mock_assignment.variant.name = "GPU"
112 + mock_assignment.accelerator.value = "T4"
113 +
114 + mock_session_state = MagicMock()
115 + mock_session_state.name = "s1"
116 + mock_session_state.endpoint = "e1"
117 + mock_session_state.running = None
118 +
119 + mock_common_state.sync_sessions.return_value = (
120 + {"s1": mock_session_state},
121 + [mock_assignment],
122 + )
123 +
124 + result = runner.invoke(app, ["sessions"])
125 + assert result.exit_code == 0
126 + assert "[s1] e1 | Hardware: T4 | Variant: GPU" in result.output
127 +
128 +
129 +def test_cli_sessions_orphaned_assignment_marked(mock_client, mock_common_state):
130 + """Server-side assignments without a local session should be marked `[?]`."""
131 + mock_assignment = MagicMock()
132 + mock_assignment.endpoint = "orphan-ep"
133 + mock_assignment.variant.name = "DEFAULT"
134 + mock_assignment.accelerator.value = "NONE"
135 +
136 + mock_common_state.sync_sessions.return_value = ({}, [mock_assignment])
137 +
138 + result = runner.invoke(app, ["sessions"])
139 + assert result.exit_code == 0
140 + # CPU is the alias for accelerator NONE
141 + assert "[?] orphan-ep | Hardware: CPU | Variant: DEFAULT" in result.output
142 +
143 +
144 +def test_cli_sessions_no_assignments(mock_client, mock_common_state):
145 + mock_common_state.sync_sessions.return_value = ({}, [])
146 + result = runner.invoke(app, ["sessions"])
147 + assert result.exit_code == 0
148 + assert "No active sessions found on server." in result.output
149 +
150 +
151 +def test_cli_status(mock_store, mock_common_state):
152 + mock_session_state = MagicMock()
153 + mock_session_state.name = "s1"
154 + mock_session_state.endpoint = "e1"
155 + mock_session_state.accelerator = "NONE"
156 + mock_session_state.variant = "DEFAULT"
157 + mock_session_state.running = None
158 + mock_session_state.last_execution = (
159 + "my_notebook.ipynb",
160 + "cell_1",
161 + "2023-10-27 12:00:00",
162 + )
163 + mock_store.get.return_value = mock_session_state
164 +
165 + mock_common_state.sync_sessions.return_value = ({"s1": mock_session_state}, [])
166 +
167 + # Test with explicit session: uses unified format including endpoint and Status
168 + result = runner.invoke(app, ["status", "-s", "s1"])
169 + assert result.exit_code == 0
170 + assert "[s1] e1 | Hardware: CPU | Variant: DEFAULT | Status: IDLE" in result.output
171 + assert (
172 + "Last Execution: my_notebook.ipynb | Cell: cell_1 at 2023-10-27 12:00:00"
173 + in result.output
174 + )
175 + mock_store.get.assert_called_with("s1")
176 +
177 + # Test with missing session
178 + mock_store.get.return_value = None
179 + result = runner.invoke(app, ["status", "-s", "missing"])
180 + assert result.exit_code == 0
181 + assert "Session 'missing' not found" in result.output
182 +
183 + # Test list all sessions: same unified format
184 + mock_store.get.return_value = mock_session_state
185 + result = runner.invoke(app, ["status"])
186 + assert result.exit_code == 0
187 + assert "[s1] e1 | Hardware: CPU | Variant: DEFAULT | Status: IDLE" in result.output
188 +
189 + # Test without execution metadata
190 + mock_session_state.last_execution = None
191 + mock_store.get.return_value = mock_session_state
192 + result = runner.invoke(app, ["status", "-s", "s1"])
193 + assert result.exit_code == 0
194 + assert "Last Execution" not in result.output
195 +
196 +
197 +def test_cli_status_running_shows_busy(mock_store, mock_common_state):
198 + mock_session_state = MagicMock()
199 + mock_session_state.name = "s1"
200 + mock_session_state.endpoint = "e1"
201 + mock_session_state.accelerator = "T4"
202 + mock_session_state.variant = "GPU"
203 + mock_session_state.running = "exec.py"
204 + mock_session_state.last_execution = None
205 + mock_store.get.return_value = mock_session_state
206 + mock_common_state.sync_sessions.return_value = ({"s1": mock_session_state}, [])
207 +
208 + result = runner.invoke(app, ["status", "-s", "s1"])
209 + assert result.exit_code == 0
210 + assert (
211 + "[s1] e1 | Hardware: T4 | Variant: GPU | Status: BUSY (exec.py)"
212 + in result.output
213 + )
214 +
215 +
216 +def test_cli_session_resolution(mock_store, mock_common_state):
217 + mock_session_state = MagicMock()
218 + mock_session_state.name = "unique-session"
219 + mock_session_state.endpoint = "e1"
220 + mock_session_state.url = "http://url"
221 + mock_session_state.token = "token"
222 + mock_session_state.kernel_id = None
223 +
224 + # Setup for resolve_session
225 + mock_common_state.resolve_session.return_value = "unique-session"
226 + mock_store.get.return_value = mock_session_state
227 +
228 + result = runner.invoke(app, ["stop"])
229 + assert result.exit_code == 0
230 + mock_store.remove.assert_called_with("unique-session")
231 +
232 +
233 +def test_cli_stop(mock_client, mock_store, mock_common_state):
234 + mock_session_state = MagicMock()
235 + mock_session_state.endpoint = "e1"
236 + mock_session_state.name = "s1"
237 + mock_session_state.url = "http://url"
238 + mock_session_state.token = "token"
239 + mock_session_state.kernel_id = None
240 + mock_store.get.return_value = mock_session_state
241 +
242 + mock_common_state.resolve_session.return_value = "s1"
243 + result = runner.invoke(app, ["stop", "-s", "s1"])
244 + assert result.exit_code == 0
245 +
246 + mock_client.unassign.assert_called_with("e1")
247 + mock_store.remove.assert_called_with("s1")
248 +
249 +
250 +def test_cli_sessions_prune(mock_common_state):
251 + mock_assignment = MagicMock()
252 + mock_session_state1 = MagicMock()
253 +
254 + mock_common_state.sync_sessions.return_value = (
255 + {"s1": mock_session_state1},
256 + [mock_assignment],
257 + )
258 + result = runner.invoke(app, ["sessions"])
259 + assert result.exit_code == 0
260 +
261 +
262 +def test_cli_new_no_name(mock_client, mock_store):
263 + mock_res = MagicMock()
264 + mock_res.__class__ = PostAssignmentResponse
265 + mock_res.runtime_proxy_info.token = "t1"
266 + mock_res.runtime_proxy_info.url = "u1"
267 + mock_res.endpoint = "e1"
268 + mock_client.assign.return_value = mock_res
269 +
270 + result = runner.invoke(app, ["new"])
271 + assert result.exit_code == 0
272 +
273 + added_state = mock_store.add.call_args[0][0]
274 + assert len(added_state.name) == 6
275 +
276 +
277 +def test_cli_new_default_is_cpu(mock_client, mock_store):
278 + """`colab new` with no flags must request a CPU runtime (no accelerator).
279 + A GPU/TPU should only be requested when --gpu or --tpu is explicitly set.
280 + """
281 + from colab_cli.client import Accelerator, Variant
282 +
283 + mock_res = MagicMock()
284 + mock_res.__class__ = PostAssignmentResponse
285 + mock_res.runtime_proxy_info.token = "t1"
286 + mock_res.runtime_proxy_info.url = "u1"
287 + mock_res.endpoint = "e1"
288 + mock_client.assign.return_value = mock_res
289 +
290 + result = runner.invoke(app, ["new"])
291 + assert result.exit_code == 0
292 +
293 + # The assign call must have used the DEFAULT (CPU) variant + NONE accelerator.
294 + _, kwargs = mock_client.assign.call_args
295 + assert kwargs["variant"] is Variant.DEFAULT
296 + assert kwargs["accelerator"] is Accelerator.NONE
297 +
298 + # The persisted SessionState should reflect the same.
299 + added_state = mock_store.add.call_args[0][0]
300 + assert added_state.variant == "DEFAULT"
301 + assert added_state.accelerator == "NONE"
302 +
303 +
304 +def test_cli_help():
305 + result = runner.invoke(app, ["--help"])
306 + assert result.exit_code == 0
307 + assert "Usage:" in result.output
308 + assert "Options" in result.output
309 + assert "Commands" in result.output
310 +
311 +
312 +def _extract_command_names(help_output: str) -> list[str]:
313 + """Parse the command list out of a Typer/Click help output rendered
314 + inside the `╭─ Commands ─...` rich box. Returns names in the order they
315 + appear.
316 + """
317 + lines = help_output.splitlines()
318 + in_commands = False
319 + names = []
320 + for line in lines:
321 + if "Commands" in line and ("─" in line or "-" in line):
322 + in_commands = True
323 + continue
324 + if in_commands:
325 + stripped = line.strip()
326 + if stripped.startswith("╰") or stripped.startswith("`"):
327 + break
328 + # Lines look like: "│ help Show help for a command. │"
329 + # Strip the rich box characters.
330 + inner = stripped.strip("│").strip()
331 + if not inner:
332 + continue
333 + tok = inner.split()[0]
334 + names.append(tok)
335 + return names
336 +
337 +
338 +def test_cli_help_commands_sorted_alphabetically():
339 + """`colab --help` should list subcommands in alphabetical order so that
340 + users (and docs) can find them deterministically."""
341 + result = runner.invoke(app, ["--help"])
342 + assert result.exit_code == 0
343 + names = _extract_command_names(result.output)
344 + assert names, f"Could not parse command names from help output:\n{result.output}"
345 + assert names == sorted(names), (
346 + f"Commands are not alphabetically sorted.\nGot: {names}\n"
347 + f"Wanted: {sorted(names)}"
348 + )
349 +
350 +
351 +def test_cli_help_subcommand_commands_sorted_alphabetically():
352 + """`colab help` (the help subcommand, no argument) should also list
353 + subcommands alphabetically — it shares the parent group's renderer."""
354 + result = runner.invoke(app, ["help"])
355 + assert result.exit_code == 0
356 + names = _extract_command_names(result.output)
357 + assert names, f"Could not parse command names from help output:\n{result.output}"
358 + assert names == sorted(names), (
359 + f"`colab help` commands are not alphabetically sorted.\nGot: {names}\n"
360 + f"Wanted: {sorted(names)}"
361 + )
362 +
363 +
364 +def test_cli_no_args():
365 + result = runner.invoke(app, [])
366 + # Typer with no_args_is_help=True might return 0 or 2 depending on version/config
367 + assert result.exit_code in [0, 2]
368 +
369 +
370 +def test_cli_console(mock_store, mock_common_state):
371 + mock_session_state = MagicMock()
372 + mock_session_state.name = "s1"
373 + mock_session_state.token = "t1"
374 + mock_session_state.url = "http://test.com"
375 + mock_store.get.return_value = mock_session_state
376 +
377 + mock_common_state.resolve_session.return_value = "s1"
378 + with patch("colab_cli.commands.execution.connect_console") as mock_connect:
379 + result = runner.invoke(app, ["console", "-s", "s1"])
380 + assert result.exit_code == 0
381 + mock_connect.assert_called_once_with(mock_session_state)
382 +
383 +
384 +@patch("colab_cli.commands.files.ContentsClient")
385 +def test_cli_ls(mock_contents_class, mock_store, mock_common_state):
386 + mock_session_state = MagicMock()
387 + mock_store.get.return_value = mock_session_state
388 +
389 + mock_contents = mock_contents_class.return_value
390 + mock_contents.list_dir.return_value = {
391 + "type": "directory",
392 + "content": [
393 + {"name": "a_dir", "type": "directory"},
394 + {"name": "b_file", "type": "file"},
395 + ],
396 + }
397 +
398 + mock_common_state.resolve_session.return_value = "s1"
399 + result = runner.invoke(app, ["ls", "-s", "s1", "content"])
400 + assert result.exit_code == 0
401 +
402 + assert "a_dir/" in result.output
403 + assert "b_file" in result.output
404 +
405 +
406 +@patch("colab_cli.commands.files.ContentsClient")
407 +def test_cli_rm(mock_contents_class, mock_store, mock_common_state):
408 + mock_session_state = MagicMock()
409 + mock_store.get.return_value = mock_session_state
410 +
411 + mock_common_state.resolve_session.return_value = "s1"
412 + result = runner.invoke(app, ["rm", "-s", "s1", "content/file.txt"])
413 + assert result.exit_code == 0
414 +
415 + mock_contents_class.return_value.rm.assert_called_once_with("content/file.txt")
416 + assert "Deleted content/file.txt" in result.output
417 +
418 +
419 +@patch("colab_cli.commands.files.os.path.isfile")
420 +@patch("colab_cli.commands.files.ContentsClient")
421 +def test_cli_upload(mock_contents_class, mock_isfile, mock_store, mock_common_state):
422 + mock_session_state = MagicMock()
423 + mock_store.get.return_value = mock_session_state
424 + mock_isfile.return_value = True
425 +
426 + mock_common_state.resolve_session.return_value = "s1"
427 + result = runner.invoke(app, ["upload", "-s", "s1", "local.txt", "remote.txt"])
428 + assert result.exit_code == 0
429 +
430 + mock_contents_class.return_value.upload.assert_called_once_with(
431 + "local.txt", "remote.txt"
432 + )
433 + assert "Uploaded 'local.txt' to 'remote.txt'" in result.output
434 +
435 +
436 +@patch("colab_cli.commands.files.ContentsClient")
437 +def test_cli_download(mock_contents_class, mock_store, mock_common_state):
438 + mock_session_state = MagicMock()
439 + mock_store.get.return_value = mock_session_state
440 +
441 + mock_common_state.resolve_session.return_value = "s1"
442 + result = runner.invoke(app, ["download", "-s", "s1", "remote.txt", "local.txt"])
443 + assert result.exit_code == 0
444 +
445 + mock_contents_class.return_value.download.assert_called_once_with(
446 + "remote.txt", "local.txt"
447 + )
448 + assert "Downloaded 'remote.txt' to 'local.txt'" in result.output
449 +
450 +
451 +@patch("colab_cli.commands.files.ContentsClient")
452 +@patch("click.edit")
453 +def test_cli_edit_no_changes(
454 + mock_edit, mock_contents_class, mock_store, mock_common_state
455 +):
456 + mock_session_state = MagicMock()
457 + mock_store.get.return_value = mock_session_state
458 +
459 + mock_common_state.resolve_session.return_value = "s1"
460 +
461 + # Simulate editor making no changes by not modifying the file
462 + def mock_edit_side_effect(filename, **kwargs):
463 + pass
464 +
465 + mock_edit.side_effect = mock_edit_side_effect
466 +
467 + result = runner.invoke(app, ["edit", "-s", "s1", "remote.txt"])
468 +
469 + assert result.exit_code == 0
470 + mock_contents_class.return_value.download.assert_called_once()
471 + mock_contents_class.return_value.upload.assert_not_called()
472 + assert "No changes made to 'remote.txt'" in result.output
473 +
474 +
475 +@patch("colab_cli.commands.files.ContentsClient")
476 +@patch("click.edit")
477 +def test_cli_edit_with_changes(
478 + mock_edit, mock_contents_class, mock_store, mock_common_state
479 +):
480 + mock_session_state = MagicMock()
481 + mock_store.get.return_value = mock_session_state
482 +
483 + mock_common_state.resolve_session.return_value = "s1"
484 +
485 + # Simulate editor modifying the file
486 + def mock_edit_side_effect(filename, **kwargs):
487 + time.sleep(0.01) # Ensure mtime differs if checking by mtime
488 + with open(filename, "a") as f:
489 + f.write("new content")
490 +
491 + mock_edit.side_effect = mock_edit_side_effect
492 +
493 + result = runner.invoke(app, ["edit", "-s", "s1", "remote.txt"])
494 +
495 + assert result.exit_code == 0
496 + mock_contents_class.return_value.download.assert_called_once()
497 + mock_contents_class.return_value.upload.assert_called_once()
498 + assert "Edited and uploaded 'remote.txt'" in result.output
499 +
500 +
501 +def _make_400_error(message="Bad Request"):
502 + """Build a ColabRequestError shaped like a 400 from the assign endpoint."""
503 + response = MagicMock()
504 + response.status_code = 400
505 + response.reason = "Bad Request"
506 + return ColabRequestError(message, request=MagicMock(), response=response)
507 +
508 +
509 +def test_cli_new_400_with_gpu_shows_friendly_error(mock_client, mock_store):
510 + """A 400 from `assign` when a GPU was requested should surface a friendly
511 + message naming the accelerator and exit non-zero, NOT raise a traceback."""
512 + mock_client.assign.side_effect = _make_400_error()
513 +
514 + result = runner.invoke(app, ["new", "--gpu", "A100"])
515 +
516 + assert result.exit_code != 0
517 + # Friendly message should mention the accelerator we asked for
518 + assert "A100" in result.output
519 + # And give actionable hints
520 + assert "quota" in result.output.lower() or "entitle" in result.output.lower()
521 + # No partial state should be saved
522 + mock_store.add.assert_not_called()
523 +
524 +
525 +def test_cli_new_400_with_tpu_shows_friendly_error(mock_client, mock_store):
526 + mock_client.assign.side_effect = _make_400_error()
527 +
528 + result = runner.invoke(app, ["new", "--tpu", "v5e1"])
529 +
530 + assert result.exit_code != 0
531 + assert "V5E1" in result.output
532 + mock_store.add.assert_not_called()
533 +
534 +
535 +def test_cli_new_400_without_accelerator_propagates(mock_client, mock_store):
536 + """If a 400 happens for a default (CPU) request, we cannot blame an
537 + accelerator. The error should propagate so the user sees the real cause
538 + rather than a misleading 'no quota' message.
539 + """
540 + mock_client.assign.side_effect = _make_400_error()
541 +
542 + # Default `colab new` requests CPU (no --gpu, no --tpu).
543 + result = runner.invoke(app, ["new"])
544 +
545 + assert result.exit_code != 0
546 + # The error message should NOT pretend it was an accelerator quota issue.
547 + assert "quota" not in result.output.lower()
548 +
549 +
550 +def test_cli_new_non_400_error_propagates(mock_client, mock_store):
551 + """Errors with non-400 status should NOT be caught by the friendly
552 + accelerator handler."""
553 + response = MagicMock()
554 + response.status_code = 500
555 + response.reason = "Internal Server Error"
556 + mock_client.assign.side_effect = ColabRequestError(
557 + "boom", request=MagicMock(), response=response
558 + )
559 +
560 + result = runner.invoke(app, ["new", "--gpu", "A100"])
561 +
562 + assert result.exit_code != 0
563 + # Should not present the 400-specific friendly text
564 + assert "quota" not in result.output.lower()
565 + mock_store.add.assert_not_called()
tests/test_cli_log.py new
+95
@@ -0,0 +1,95 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import sys
16 +import pytest
17 +from unittest.mock import patch
18 +from colab_cli.cli import main
19 +
20 +
21 +def test_cli_log_list():
22 + with patch.object(sys, "argv", ["colab", "log"]):
23 + with patch("colab_cli.commands.utility.state") as mock_state:
24 + mock_state.history.list_sessions.return_value = ["s1"]
25 +
26 + with patch("colab_cli.commands.utility.typer.echo") as mock_print:
27 + with pytest.raises(SystemExit) as exitinfo:
28 + main()
29 + assert exitinfo.value.code == 0
30 + mock_print.assert_any_call(" s1")
31 +
32 +
33 +def test_cli_log_show():
34 + with patch.object(sys, "argv", ["colab", "log", "-s", "s1"]):
35 + with patch("colab_cli.commands.utility.state") as mock_state:
36 + mock_state.history.get_history.return_value = [
37 + {
38 + "timestamp": "2026-03-23T12:00:00.000000+00:00",
39 + "event_type": "execution",
40 + "code": "print(1)",
41 + }
42 + ]
43 +
44 + with patch("colab_cli.commands.utility.typer.echo") as mock_print:
45 + with pytest.raises(SystemExit) as exitinfo:
46 + main()
47 + assert exitinfo.value.code == 0
48 + # Check for EXEC: print(1)
49 + found = any(
50 + "EXEC: print(1)" in str(call) for call in mock_print.call_args_list
51 + )
52 + assert found
53 +
54 +
55 +def test_cli_log_show_filter():
56 + with patch.object(
57 + sys, "argv", ["colab", "log", "-s", "s1", "-t", "file_operation"]
58 + ):
59 + with patch("colab_cli.commands.utility.state") as mock_state:
60 + mock_state.history.get_history.return_value = [
61 + {
62 + "timestamp": "2026-03-23T12:00:00.000000+00:00",
63 + "event_type": "execution",
64 + "code": "print(1)",
65 + },
66 + {
67 + "timestamp": "2026-03-23T12:01:00.000000+00:00",
68 + "event_type": "file_operation",
69 + "op": "ls",
70 + "path": "content",
71 + },
72 + ]
73 +
74 + with patch("colab_cli.commands.utility.typer.echo") as mock_print:
75 + with pytest.raises(SystemExit) as exitinfo:
76 + main()
77 + assert exitinfo.value.code == 0
78 + # Should see FILE: ls content but NOT EXEC: print(1)
79 + printed = [str(call) for call in mock_print.call_args_list]
80 + assert any("FILE: ls" in p for p in printed)
81 + assert not any("EXEC: print(1)" in p for p in printed)
82 +
83 +
84 +def test_cli_log_export():
85 + with patch.object(sys, "argv", ["colab", "log", "-s", "s1", "-o", "test.ipynb"]):
86 + with patch("colab_cli.commands.utility.state") as mock_state:
87 + with patch("colab_cli.converter.export_history") as mock_export:
88 + mock_state.history.get_history.return_value = [
89 + {"event_type": "execution"}
90 + ]
91 +
92 + with pytest.raises(SystemExit) as exitinfo:
93 + main()
94 + assert exitinfo.value.code == 0
95 + mock_export.assert_called_once()
tests/test_client.py new
+198
@@ -0,0 +1,198 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import uuid
16 +import json
17 +import pytest
18 +from unittest.mock import MagicMock
19 +from colab_cli.client import Client, Prod, PostAssignmentResponse, Assignment
20 +
21 +
22 +@pytest.fixture
23 +def mock_session():
24 + return MagicMock()
25 +
26 +
27 +@pytest.fixture
28 +def client(mock_session):
29 + return Client(Prod(), mock_session)
30 +
31 +
32 +def test_client_assign_new(client, mock_session):
33 + # Mock _get_assignment (GET)
34 + get_resp = MagicMock()
35 + get_resp.ok = True
36 + get_resp.text = ")]}'\n" + json.dumps(
37 + {"acc": "NONE", "nbh": "some_nbh", "token": "xsrf_token", "variant": "DEFAULT"}
38 + )
39 +
40 + # Mock _post_assignment (POST)
41 + post_resp = MagicMock()
42 + post_resp.ok = True
43 + post_resp.text = ")]}'\n" + json.dumps(
44 + {
45 + "accelerator": "NONE",
46 + "endpoint": "new_endpoint",
47 + "runtimeProxyInfo": {
48 + "token": "proxy_token",
49 + "tokenExpiresInSeconds": 3600,
50 + "url": "http://backend",
51 + },
52 + "variant": 0,
53 + }
54 + )
55 +
56 + mock_session.request.side_effect = [get_resp, post_resp]
57 +
58 + res = client.assign(uuid.uuid4())
59 +
60 + assert isinstance(res, PostAssignmentResponse)
61 + assert res.endpoint == "new_endpoint"
62 + assert res.runtime_proxy_info.token == "proxy_token"
63 +
64 + # Check POST request headers for XSRF token
65 + assert mock_session.request.call_count == 2
66 + last_call_args = mock_session.request.call_args_list[1]
67 + assert last_call_args.kwargs["headers"]["X-Goog-Colab-Token"] == "xsrf_token"
68 +
69 +
70 +def test_client_unassign(client, mock_session):
71 + # Mock GET for XSRF token
72 + get_resp = MagicMock()
73 + get_resp.ok = True
74 + get_resp.text = ")]}'\n" + json.dumps({"token": "unassign_xsrf_token"})
75 +
76 + # Mock POST for unassign
77 + post_resp = MagicMock()
78 + post_resp.ok = True
79 + post_resp.text = "" # 204 No Content typically
80 +
81 + mock_session.request.side_effect = [get_resp, post_resp]
82 +
83 + client.unassign("my_endpoint")
84 +
85 + assert mock_session.request.call_count == 2
86 + last_call_args = mock_session.request.call_args_list[1]
87 + assert (
88 + last_call_args.kwargs["headers"]["X-Goog-Colab-Token"] == "unassign_xsrf_token"
89 + )
90 + assert "unassign/my_endpoint" in last_call_args.args[1]
91 +
92 +
93 +def test_client_assign_existing(client, mock_session):
94 + # Mock _get_assignment (GET) returning existing Assignment
95 + get_resp = MagicMock()
96 + get_resp.ok = True
97 + get_resp.text = ")]}'\n" + json.dumps(
98 + {
99 + "endpoint": "existing_endpoint",
100 + "runtimeProxyInfo": {
101 + "token": "existing_token",
102 + "tokenExpiresInSeconds": 3600,
103 + "url": "http://existing-backend",
104 + },
105 + }
106 + )
107 +
108 + mock_session.request.return_value = get_resp
109 +
110 + res = client.assign(uuid.uuid4())
111 +
112 + assert isinstance(res, Assignment)
113 + assert res.endpoint == "existing_endpoint"
114 + assert mock_session.request.call_count == 1
115 +
116 +
117 +def test_client_list_assignments(client, mock_session):
118 + # Mock list_assignments (GET)
119 + resp = MagicMock()
120 + resp.ok = True
121 + resp.text = ")]}'\n" + json.dumps(
122 + {
123 + "assignments": [
124 + {
125 + "accelerator": "NONE",
126 + "endpoint": "e1",
127 + "variant": 0,
128 + "machineShape": 0,
129 + "runtimeProxyInfo": {
130 + "token": "t1",
131 + "tokenExpiresInSeconds": 3600,
132 + "url": "u1",
133 + },
134 + }
135 + ]
136 + }
137 + )
138 +
139 + mock_session.request.return_value = resp
140 +
141 + # This should fail if list_assignments is not implemented
142 + res = client.list_assignments()
143 +
144 + assert len(res) == 1
145 + assert res[0].endpoint == "e1"
146 + assert "tun/m/assignments" in mock_session.request.call_args.args[1]
147 +
148 +
149 +def test_client_keep_alive_assignment_handles_empty_array_response(
150 + client, mock_session
151 +):
152 + """KeepAliveAssignment returns `[]` on success (grpc-web protojson). When the
153 + caller passes no `schema=`, _issue_request must not try to validate the
154 + body — otherwise it raises pydantic ValidationError on the empty list.
155 + Regression: discovered live 2026-04-30."""
156 + resp = MagicMock()
157 + resp.ok = True
158 + resp.text = "[]"
159 + mock_session.request.return_value = resp
160 +
161 + # Should NOT raise.
162 + result = client.keep_alive_assignment("m-s-test")
163 + assert result is None # no schema, so no return value
164 +
165 +
166 +def test_client_keep_alive_assignment_request_shape(client, mock_session):
167 + """The RuntimeService rejects the request with HTTP 400 unless
168 + `X-Goog-Api-Client` contains `grpc-web`. This test pins the wire format
169 + that talks to colab.pa.googleapis.com.
170 + """
171 + resp = MagicMock()
172 + resp.ok = True
173 + resp.text = ""
174 + mock_session.request.return_value = resp
175 +
176 + client.keep_alive_assignment("m-s-test-endpoint")
177 +
178 + assert mock_session.request.call_count == 1
179 + call = mock_session.request.call_args
180 + method, url = call.args[0], call.args[1]
181 + headers = call.kwargs["headers"]
182 + body = call.kwargs["json"]
183 +
184 + assert method == "POST"
185 + assert url.endswith(
186 + "/$rpc/google.internal.colab.v1.RuntimeService/KeepAliveAssignment"
187 + )
188 + # Positional protojson encoding: a single-element array with the endpoint.
189 + assert body == ["m-s-test-endpoint"]
190 + assert headers["Content-Type"] == "application/json+protobuf"
191 + assert "x-goog-api-key" in headers
192 + assert headers["x-user-agent"] == "grpc-web-javascript/0.1"
193 + # Critical: server requires `grpc-web` substring in this header.
194 + assert "grpc-web" in headers["x-goog-api-client"]
195 + # Critical: pin consumer project to Colab's, otherwise ADC user creds
196 + # (which carry their own gcloud quota project) trigger HTTP 400
197 + # CONSUMER_INVALID. Verified empirically 2026-04-30.
198 + assert headers["x-goog-user-project"] == "1014160490159"
tests/test_console.py new
+222
@@ -0,0 +1,222 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import json
16 +import os
17 +import sys
18 +import termios
19 +from unittest.mock import MagicMock, patch
20 +
21 +from colab_cli.console import connect_console, on_message, on_open
22 +from colab_cli.state import SessionState
23 +import pytest
24 +
25 +
26 +@pytest.fixture
27 +def mock_session():
28 + return SessionState(
29 + name="test-session",
30 + token="test-token",
31 + url="https://8080-m-s-kkb-usc1f1.us-central1-1.colab.dev",
32 + endpoint="some-endpoint",
33 + )
34 +
35 +
36 +@patch("colab_cli.console.websocket.WebSocketApp")
37 +@patch("colab_cli.console.tty.setraw")
38 +@patch("colab_cli.console.termios.tcgetattr")
39 +@patch("colab_cli.console.termios.tcsetattr")
40 +@patch("colab_cli.console.os.get_terminal_size")
41 +@patch("colab_cli.console.sys.stdin.fileno")
42 +@patch("colab_cli.console.sys.stdin.isatty")
43 +def test_console_initialization(
44 + mock_isatty,
45 + mock_fileno,
46 + mock_get_term_size,
47 + mock_tcsetattr,
48 + mock_tcgetattr,
49 + mock_setraw,
50 + mock_ws_app,
51 + mock_session,
52 +):
53 + # Setup mocks
54 + mock_isatty.return_value = True
55 + mock_fileno.return_value = 0
56 + mock_get_term_size.return_value = os.terminal_size((80, 24))
57 + mock_tcgetattr.return_value = ["fake_attrs"]
58 + mock_ws_instance = MagicMock()
59 + mock_ws_app.return_value = mock_ws_instance
60 +
61 + # We don't want run_forever to actually block or start threads in the test
62 + mock_ws_instance.run_forever.return_value = None
63 +
64 + with patch("colab_cli.console.threading.Thread"):
65 + connect_console(mock_session)
66 +
67 + # 1. Verify URL transformation
68 + expected_url = "wss://8080-m-s-kkb-usc1f1.us-central1-1.colab.dev/colab/tty?colab-runtime-proxy-token=test-token"
69 + mock_ws_app.assert_called_once()
70 + assert mock_ws_app.call_args[1]["url"] == expected_url
71 +
72 + # 2. Verify raw mode setup and teardown
73 + mock_tcgetattr.assert_called_once_with(sys.stdin.fileno())
74 + mock_setraw.assert_called_once_with(sys.stdin.fileno(), termios.TCSANOW)
75 +
76 + # Teardown should happen in a finally block
77 + mock_tcsetattr.assert_called_once_with(
78 + sys.stdin.fileno(), termios.TCSANOW, ["fake_attrs"]
79 + )
80 +
81 +
82 +@patch("colab_cli.console.websocket.WebSocketApp")
83 +@patch("colab_cli.console.tty.setraw")
84 +@patch("colab_cli.console.termios.tcgetattr")
85 +@patch("colab_cli.console.termios.tcsetattr")
86 +@patch("colab_cli.console.sys.stdin.isatty")
87 +def test_console_piped_input(
88 + mock_isatty,
89 + mock_tcsetattr,
90 + mock_tcgetattr,
91 + mock_setraw,
92 + mock_ws_app,
93 + mock_session,
94 +):
95 + mock_isatty.return_value = False
96 + mock_ws_instance = MagicMock()
97 + mock_ws_app.return_value = mock_ws_instance
98 + mock_ws_instance.run_forever.return_value = None
99 +
100 + with patch("colab_cli.console.threading.Thread"):
101 + connect_console(mock_session)
102 +
103 + # In a piped environment, we should not attempt to use termios or tty
104 + mock_tcgetattr.assert_not_called()
105 + mock_setraw.assert_not_called()
106 + mock_tcsetattr.assert_not_called()
107 +
108 +
109 +@patch("colab_cli.console.os.get_terminal_size")
110 +def test_on_open_sends_terminal_size(mock_get_term_size):
111 + mock_ws = MagicMock()
112 + mock_get_term_size.return_value = os.terminal_size((100, 40))
113 +
114 + on_open(mock_ws)
115 +
116 + # Verify that the initial terminal size is sent
117 + mock_ws.send.assert_called_once()
118 + payload = json.loads(mock_ws.send.call_args[0][0])
119 + assert payload == {"cols": 100, "rows": 40}
120 +
121 +
122 +@patch("colab_cli.console.sys.stdout.buffer.write")
123 +@patch("colab_cli.console.sys.stdout.buffer.flush")
124 +def test_on_message_writes_to_stdout(mock_flush, mock_write):
125 + mock_ws = MagicMock()
126 + test_data = "Hello \x1b[34mWorld\x1b[0m"
127 + message_json = json.dumps({"data": test_data})
128 +
129 + on_message(mock_ws, message_json)
130 +
131 + # Verify that the data is written exactly as received
132 + mock_write.assert_called_once_with(test_data.encode("utf-8"))
133 + mock_flush.assert_called_once()
134 +
135 +
136 +@patch("colab_cli.console.os.get_terminal_size")
137 +@patch("colab_cli.console.sys.stdin.isatty")
138 +@patch("colab_cli.console.sys.stdin")
139 +def test_read_stdin_eof_piped_sends_exit_and_closes_ws(
140 + mock_stdin, mock_isatty, mock_get_term_size
141 +):
142 + """When stdin is piped and reaches EOF, the read thread should send 'exit\\n'
143 + to the remote shell and then close the websocket from the client side.
144 +
145 + The remote shell at /colab/tty is wrapped in tmux which swallows the bare
146 + \\x04 (Ctrl-D) we used to send, so EOF used to leave the websocket open
147 + indefinitely. Sending 'exit\\n' + ws.close() guarantees clean termination.
148 + """
149 + import colab_cli.console as console_mod
150 +
151 + mock_isatty.return_value = False
152 + # Simulate piped stdin: returns one line then EOF
153 + mock_stdin.read.side_effect = ["e", "c", "h", "o", " ", "h", "i", "\n", ""]
154 + mock_get_term_size.return_value = os.terminal_size((80, 24))
155 +
156 + mock_ws = MagicMock()
157 +
158 + # on_open spawns the read thread; we want it to run synchronously here
159 + # so we patch threading.Thread to call target immediately and join().
160 + real_thread = []
161 +
162 + class SyncThread:
163 + def __init__(self, target, daemon=None):
164 + self.target = target
165 + real_thread.append(self)
166 +
167 + def start(self):
168 + self.target()
169 +
170 + console_mod._is_running = True
171 + with patch("colab_cli.console.threading.Thread", SyncThread):
172 + # Use a tiny grace period for the test
173 + with patch("colab_cli.console.PIPED_EOF_GRACE_SECONDS", 0.01):
174 + on_open(mock_ws)
175 +
176 + # Collect what was sent to the websocket
177 + sent_payloads = [json.loads(c.args[0]) for c in mock_ws.send.call_args_list]
178 +
179 + # Initial send is the terminal size; everything after is stdin chars or our exit string.
180 + # Verify "exit\n" was sent on EOF (one send per character)
181 + assert {"data": "exit\n"} in sent_payloads, (
182 + f"Expected 'exit\\n' to be sent on piped EOF, got: {sent_payloads}"
183 + )
184 +
185 + # Verify we closed the websocket from the client side
186 + mock_ws.close.assert_called_once()
187 +
188 +
189 +@patch("colab_cli.console.os.get_terminal_size")
190 +@patch("colab_cli.console.sys.stdin.isatty")
191 +@patch("colab_cli.console.sys.stdin")
192 +def test_read_stdin_eof_tty_does_not_close_ws(
193 + mock_stdin, mock_isatty, mock_get_term_size
194 +):
195 + """When stdin is a real TTY and read() returns empty (which happens on
196 + Ctrl-D in raw mode), we should NOT inject 'exit\\n' or close the websocket
197 + \u2014 the user is in interactive mode and may have intended Ctrl-D as a literal
198 + char. The websocket lifecycle is owned by the remote shell in this case.
199 + """
200 + import colab_cli.console as console_mod
201 +
202 + mock_isatty.return_value = True
203 + # TTY EOF is rare but possible; should be passed through transparently
204 + mock_stdin.read.side_effect = [""]
205 + mock_get_term_size.return_value = os.terminal_size((80, 24))
206 +
207 + mock_ws = MagicMock()
208 +
209 + class SyncThread:
210 + def __init__(self, target, daemon=None):
211 + self.target = target
212 +
213 + def start(self):
214 + self.target()
215 +
216 + console_mod._is_running = True
217 + with patch("colab_cli.console.threading.Thread", SyncThread):
218 + on_open(mock_ws)
219 +
220 + sent_payloads = [json.loads(c.args[0]) for c in mock_ws.send.call_args_list]
221 + assert {"data": "exit\n"} not in sent_payloads
222 + mock_ws.close.assert_not_called()
tests/test_contents.py new
+152
@@ -0,0 +1,152 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import base64
16 +from unittest.mock import MagicMock, patch
17 +
18 +import pytest
19 +from colab_cli.contents import ContentsClient
20 +from requests import Response
21 +
22 +from colab_cli.state import SessionState
23 +
24 +
25 +@pytest.fixture
26 +def session():
27 + return SessionState(
28 + name="test-session",
29 + token="test-token",
30 + url="https://fake-endpoint.colab.dev",
31 + endpoint="endpoint",
32 + )
33 +
34 +
35 +@pytest.fixture
36 +def client(session):
37 + return ContentsClient(session)
38 +
39 +
40 +@patch("colab_cli.contents.requests.request")
41 +def test_list_dir(mock_request, client):
42 + mock_resp = MagicMock(spec=Response)
43 + mock_resp.status_code = 200
44 + mock_resp.json.return_value = {
45 + "name": "content",
46 + "type": "directory",
47 + "content": [
48 + {"name": "file.txt", "type": "file"},
49 + {"name": "dir", "type": "directory"},
50 + ],
51 + }
52 + mock_request.return_value = mock_resp
53 +
54 + res = client.list_dir("content")
55 +
56 + mock_request.assert_called_once_with(
57 + "GET",
58 + "https://fake-endpoint.colab.dev/api/contents/content",
59 + params={"authuser": "0", "colab-runtime-proxy-token": "test-token"},
60 + json=None,
61 + )
62 + assert res["type"] == "directory"
63 + assert len(res["content"]) == 2
64 +
65 +
66 +@patch("colab_cli.contents.requests.request")
67 +def test_rm_file(mock_request, client):
68 + mock_resp = MagicMock(spec=Response)
69 + mock_resp.status_code = 204
70 + mock_request.return_value = mock_resp
71 +
72 + client.rm("content/file.txt")
73 +
74 + mock_request.assert_called_once_with(
75 + "DELETE",
76 + "https://fake-endpoint.colab.dev/api/contents/content/file.txt",
77 + params={"authuser": "0", "colab-runtime-proxy-token": "test-token"},
78 + json=None,
79 + )
80 +
81 +
82 +@patch("colab_cli.contents.requests.request")
83 +def test_404_error(mock_request, client):
84 + mock_resp = MagicMock(spec=Response)
85 + mock_resp.status_code = 404
86 + mock_request.return_value = mock_resp
87 +
88 + with pytest.raises(FileNotFoundError):
89 + client.list_dir("nonexistent")
90 +
91 +
92 +@patch("colab_cli.contents.requests.request")
93 +def test_download_file(mock_request, client, tmp_path):
94 + mock_resp = MagicMock(spec=Response)
95 + mock_resp.status_code = 200
96 +
97 + # Mocking a base64 encoded response
98 + content_bytes = b"Hello world!"
99 + b64_content = base64.b64encode(content_bytes).decode("ascii")
100 +
101 + mock_resp.json.return_value = {
102 + "name": "test.txt",
103 + "type": "file",
104 + "format": "base64",
105 + "content": b64_content,
106 + }
107 + mock_request.return_value = mock_resp
108 +
109 + local_file = tmp_path / "test.txt"
110 + client.download("content/test.txt", str(local_file))
111 +
112 + mock_request.assert_called_once_with(
113 + "GET",
114 + "https://fake-endpoint.colab.dev/api/contents/content/test.txt",
115 + params={
116 + "authuser": "0",
117 + "colab-runtime-proxy-token": "test-token",
118 + "content": "1",
119 + },
120 + json=None,
121 + )
122 +
123 + assert local_file.read_bytes() == content_bytes
124 +
125 +
126 +@patch("colab_cli.contents.requests.request")
127 +def test_upload_file(mock_request, client, tmp_path):
128 + mock_resp = MagicMock(spec=Response)
129 + mock_resp.status_code = 200
130 + mock_request.return_value = mock_resp
131 +
132 + local_file = tmp_path / "test.txt"
133 + content_bytes = b"Hello upload!"
134 + local_file.write_bytes(content_bytes)
135 +
136 + client.upload(str(local_file), "content/test.txt")
137 +
138 + expected_b64 = base64.b64encode(content_bytes).decode("ascii")
139 +
140 + mock_request.assert_called_once_with(
141 + "PUT",
142 + "https://fake-endpoint.colab.dev/api/contents/content/test.txt",
143 + params={"authuser": "0", "colab-runtime-proxy-token": "test-token"},
144 + json={
145 + "name": "test.txt",
146 + "path": "content/test.txt",
147 + "type": "file",
148 + "format": "base64",
149 + "content": expected_b64,
150 + "chunk": 1,
151 + },
152 + )
tests/test_exec.py new
+178
@@ -0,0 +1,178 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock, patch, ANY
16 +
17 +import pytest
18 +from typer.testing import CliRunner
19 +
20 +from colab_cli.cli import app
21 +
22 +runner = CliRunner()
23 +
24 +
25 +@pytest.fixture
26 +def mock_store(mock_common_state):
27 + return mock_common_state.store
28 +
29 +
30 +@pytest.fixture
31 +def mock_runtime_class(mocker):
32 + # Patch it in the command module where it's used
33 + return mocker.patch("colab_cli.commands.execution.ColabRuntime")
34 +
35 +
36 +def test_cli_exec_file(mock_store, mock_runtime_class, mock_common_state, tmp_path):
37 + mock_session = MagicMock()
38 + mock_session.url = "http://url"
39 + mock_session.token = "token123"
40 + mock_session.name = "s1"
41 + mock_session.kernel_id = None
42 + mock_session.session_id = None
43 + mock_store.get.return_value = mock_session
44 +
45 + mock_common_state.resolve_session.return_value = "s1"
46 + mock_runtime = mock_runtime_class.return_value
47 + mock_runtime.execute_code.return_value = [{"text": "hello\n"}]
48 +
49 + script = tmp_path / "script.py"
50 + script.write_text("print('hello')")
51 +
52 + result = runner.invoke(app, ["exec", "-s", "s1", "-f", str(script)])
53 + assert result.exit_code == 0
54 + assert mock_session.last_execution[0] == str(script)
55 + assert mock_session.last_execution[1] is None
56 + assert mock_session.last_execution[2] is not None
57 + mock_store.add.assert_called_with(mock_session)
58 + mock_runtime.execute_code.assert_any_call(
59 + "import os; os.makedirs('/content', exist_ok=True); os.chdir('/content')"
60 + )
61 + mock_runtime.execute_code.assert_any_call("print('hello')", output_hook=ANY)
62 +
63 +
64 +def test_cli_exec_stdin(mock_store, mock_runtime_class, mock_common_state):
65 + mock_session = MagicMock()
66 + mock_session.name = "s1"
67 + mock_session.url = "http://url"
68 + mock_session.token = "token"
69 + mock_session.kernel_id = None
70 + mock_session.session_id = None
71 + mock_store.get.return_value = mock_session
72 +
73 + mock_common_state.resolve_session.return_value = "s1"
74 + mock_runtime = mock_runtime_class.return_value
75 + mock_runtime.execute_code.return_value = [{"data": {"text/plain": "42"}}]
76 +
77 + result = runner.invoke(app, ["exec", "-s", "s1"], input="print(42)")
78 + assert result.exit_code == 0
79 + assert mock_session.last_execution[0] == "stdin"
80 + assert mock_session.last_execution[1] is None
81 + assert mock_session.last_execution[2] is not None
82 + mock_store.add.assert_called_with(mock_session)
83 + mock_runtime.execute_code.assert_any_call("print(42)", output_hook=ANY)
84 +
85 +
86 +def test_cli_exec_not_found(mock_common_state):
87 + # Case where resolve_session fails
88 + mock_common_state.resolve_session.side_effect = SystemExit(1)
89 + result = runner.invoke(app, ["exec", "-s", "missing"])
90 + assert result.exit_code == 1
91 +
92 +
93 +def test_cli_exec_no_input(mock_store, mock_common_state, mocker):
94 + mock_session = MagicMock()
95 + mock_session.name = "s1"
96 + mock_store.get.return_value = mock_session
97 +
98 + mock_common_state.resolve_session.return_value = "s1"
99 +
100 + # Mock is_stdin_tty to True to trigger the "No input provided" error
101 + mocker.patch("colab_cli.commands.execution.is_stdin_tty", return_value=True)
102 +
103 + result = runner.invoke(app, ["exec", "-s", "s1"])
104 + assert result.exit_code == 1
105 + assert "No input provided" in result.output
106 +
107 +
108 +@patch("colab_cli.commands.execution.handle_image")
109 +def test_cli_exec_outputs(
110 + mock_handle_image, mock_store, mock_runtime_class, mock_common_state
111 +):
112 + mock_session = MagicMock()
113 + mock_session.name = "s1"
114 + mock_session.url = "http://url"
115 + mock_session.token = "token"
116 + mock_session.kernel_id = None
117 + mock_session.session_id = None
118 + mock_store.get.return_value = mock_session
119 +
120 + mock_common_state.resolve_session.return_value = "s1"
121 + mock_runtime = mock_runtime_class.return_value
122 +
123 + # We need to simulate the output_hook being called because the command now relies on it
124 + # for immediate output, although it also returns the final list.
125 + def mock_execute_code(code, output_hook=None, **kwargs):
126 + outputs = [
127 + {"data": {"image/png": "png_data"}},
128 + {"data": {"image/jpeg": "jpeg_data"}},
129 + {"output_type": "error", "ename": "ValueError", "evalue": "bad"},
130 + {"output_type": "error", "traceback": ["line1\n", "line2\n"]},
131 + ]
132 + if output_hook:
133 + for o in outputs:
134 + output_hook(o)
135 + return outputs
136 +
137 + mock_runtime.execute_code.side_effect = mock_execute_code
138 +
139 + result = runner.invoke(app, ["exec", "-s", "s1"], input="do_stuff()")
140 + assert result.exit_code == 0
141 +
142 + mock_handle_image.assert_any_call("png_data", "image/png", target_path=None)
143 + mock_handle_image.assert_any_call("jpeg_data", "image/jpeg", target_path=None)
144 +
145 + assert "ValueError: bad\n" in result.stderr
146 + assert "line1\nline2\n" in result.stderr
147 +
148 +
149 +def test_cli_exec_empty_code(mock_runtime_class, mock_store, mock_common_state):
150 + mock_session = MagicMock()
151 + mock_session.name = "s1"
152 + mock_session.url = "http://url"
153 + mock_session.token = "token"
154 + mock_session.kernel_id = None
155 + mock_session.session_id = None
156 + mock_store.get.return_value = mock_session
157 +
158 + mock_common_state.resolve_session.return_value = "s1"
159 + result = runner.invoke(app, ["exec", "-s", "s1"], input=" \n ")
160 + assert result.exit_code == 0
161 +
162 +
163 +def test_cli_exec_lost_session_prunes(
164 + mock_runtime_class, mock_store, mock_common_state
165 +):
166 + mock_session = MagicMock()
167 + mock_session.name = "lost-sess"
168 + mock_store.get.return_value = mock_session
169 + mock_common_state.resolve_session.return_value = "lost-sess"
170 +
171 + mock_runtime = mock_runtime_class.return_value
172 + # Simulate 404 during initialization
173 + mock_runtime.execute_code.side_effect = Exception("404 Not Found")
174 +
175 + result = runner.invoke(app, ["exec", "-s", "lost-sess"], input="print(1)")
176 + assert result.exit_code == 1
177 + assert "appears to be lost" in result.output
178 + mock_common_state.prune_session.assert_called_once_with("lost-sess")
tests/test_history.py new
+52
@@ -0,0 +1,52 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import tempfile
16 +import shutil
17 +import unittest
18 +from colab_cli.history import HistoryLogger
19 +
20 +
21 +class TestHistory(unittest.TestCase):
22 + def setUp(self):
23 + self.test_dir = tempfile.mkdtemp()
24 + self.logger = HistoryLogger(log_dir=self.test_dir)
25 +
26 + def tearDown(self):
27 + shutil.rmtree(self.test_dir)
28 +
29 + def test_log_and_get_history(self):
30 + self.logger.log_event("test-session", "session_created", {"variant": "DEFAULT"})
31 + self.logger.log_event(
32 + "test-session", "execution", {"code": "print(1)", "outputs": []}
33 + )
34 +
35 + history = self.logger.get_history("test-session")
36 + self.assertEqual(len(history), 2)
37 + self.assertEqual(history[0]["event_type"], "session_created")
38 + self.assertEqual(history[1]["event_type"], "execution")
39 + self.assertEqual(history[1]["code"], "print(1)")
40 +
41 + def test_list_sessions(self):
42 + self.logger.log_event("s1", "event", {})
43 + self.logger.log_event("s2", "event", {})
44 +
45 + sessions = self.logger.list_sessions()
46 + self.assertIn("s1", sessions)
47 + self.assertIn("s2", sessions)
48 + self.assertEqual(len(sessions), 2)
49 +
50 +
51 +if __name__ == "__main__":
52 + unittest.main()
tests/test_ipynb_exec.py new
+216
@@ -0,0 +1,216 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import json
16 +import nbformat
17 +import os
18 +import pytest
19 +import shutil
20 +import sys
21 +import tempfile
22 +import unittest
23 +from unittest.mock import patch, MagicMock, ANY
24 +from colab_cli.cli import main
25 +
26 +
27 +class TestIpynbExec(unittest.TestCase):
28 + def setUp(self):
29 + self.temp_dir = tempfile.mkdtemp()
30 + self.nb_path = os.path.join(self.temp_dir, "test.ipynb")
31 +
32 + # Create a simple v4 notebook
33 + nb = {
34 + "cells": [
35 + {
36 + "cell_type": "code",
37 + "execution_count": None,
38 + "id": "cell1",
39 + "metadata": {},
40 + "outputs": [],
41 + "source": "print('cell 1')",
42 + },
43 + {
44 + "cell_type": "markdown",
45 + "id": "cell2",
46 + "metadata": {},
47 + "source": "# Markdown cell",
48 + },
49 + {
50 + "cell_type": "code",
51 + "execution_count": None,
52 + "id": "cell3",
53 + "metadata": {},
54 + "outputs": [],
55 + "source": "print('cell 2')",
56 + },
57 + ],
58 + "metadata": {},
59 + "nbformat": 4,
60 + "nbformat_minor": 5,
61 + }
62 + with open(self.nb_path, "w", encoding="utf-8") as f:
63 + json.dump(nb, f)
64 +
65 + def tearDown(self):
66 + shutil.rmtree(self.temp_dir)
67 +
68 + @patch("colab_cli.commands.execution.ColabRuntime")
69 + @patch("colab_cli.state.StateStore")
70 + def test_exec_ipynb(
71 + self,
72 + mock_store_class,
73 + mock_runtime_class,
74 + ):
75 + with patch.object(
76 + sys, "argv", ["colab", "exec", "-s", "test-s", "-f", self.nb_path]
77 + ):
78 + mock_store = mock_store_class.return_value
79 + mock_store.get.return_value = MagicMock(
80 + name="test-s", url="http://url", token="token"
81 + )
82 +
83 + mock_runtime = mock_runtime_class.return_value
84 + mock_runtime.execute_code.side_effect = [
85 + [], # os.makedirs and os.chdir setup
86 + [{"text": "cell 1\n"}],
87 + [{"text": "cell 2\n"}],
88 + ]
89 +
90 + with patch("builtins.print"), pytest.raises(SystemExit) as error:
91 + main()
92 +
93 + assert error.value.code == 0
94 +
95 + # Verify both code cells were executed (plus the setup cell)
96 + self.assertEqual(mock_runtime.execute_code.call_count, 3)
97 + self.assertIn(
98 + "os.chdir", mock_runtime.execute_code.call_args_list[0].args[0]
99 + )
100 + mock_runtime.execute_code.assert_any_call(
101 + "print('cell 1')", output_hook=ANY
102 + )
103 + mock_runtime.execute_code.assert_any_call(
104 + "print('cell 2')", output_hook=ANY
105 + )
106 +
107 + @patch("colab_cli.commands.execution.ColabRuntime")
108 + @patch("colab_cli.state.StateStore")
109 + @patch("colab_cli.commands.execution.typer.echo")
110 + def test_exec_ipynb_output_format(
111 + self,
112 + mock_echo,
113 + mock_store_class,
114 + mock_runtime_class,
115 + ):
116 + nb_path = os.path.join(self.temp_dir, "test_format.ipynb")
117 + nb = {
118 + "cells": [
119 + {
120 + "cell_type": "code",
121 + "execution_count": None,
122 + "id": "my-cell-id-123",
123 + "metadata": {},
124 + "outputs": [],
125 + "source": "# @title My Special Cell\nprint('hello')",
126 + },
127 + {
128 + "cell_type": "code",
129 + "execution_count": None,
130 + "id": "fallback-id-456",
131 + "metadata": {},
132 + "outputs": [],
133 + "source": "print('world')",
134 + },
135 + ],
136 + "metadata": {},
137 + "nbformat": 4,
138 + "nbformat_minor": 5,
139 + }
140 + with open(nb_path, "w", encoding="utf-8") as f:
141 + json.dump(nb, f)
142 +
143 + with patch.object(
144 + sys, "argv", ["colab", "exec", "-s", "test-s", "-f", nb_path]
145 + ):
146 + mock_store = mock_store_class.return_value
147 + mock_store.get.return_value = MagicMock(
148 + name="test-s", url="http://url", token="token"
149 + )
150 +
151 + mock_runtime = mock_runtime_class.return_value
152 + mock_runtime.execute_code.side_effect = [
153 + [], # setup
154 + [{"text": "hello\n"}],
155 + [{"text": "world\n"}],
156 + ]
157 +
158 + with patch("builtins.print"), pytest.raises(SystemExit) as error:
159 + main()
160 +
161 + assert error.value.code == 0
162 +
163 + mock_echo.assert_any_call("[colab] Executing cell 1/2 - My Special Cell...")
164 + mock_echo.assert_any_call("[colab] Executing cell 2/2 - fallback-id-456...")
165 +
166 + @patch("colab_cli.commands.execution.ColabRuntime")
167 + @patch("colab_cli.state.StateStore")
168 + @patch("colab_cli.commands.execution.typer.echo")
169 + def test_exec_ipynb_creates_output_file(
170 + self,
171 + mock_echo,
172 + mock_store_class,
173 + mock_runtime_class,
174 + ):
175 + with patch.object(
176 + sys, "argv", ["colab", "exec", "-s", "test-s", "-f", self.nb_path]
177 + ):
178 + mock_store = mock_store_class.return_value
179 + mock_store.get.return_value = MagicMock(
180 + name="test-s", url="http://url", token="token"
181 + )
182 +
183 + mock_runtime = mock_runtime_class.return_value
184 + mock_runtime.execute_code.side_effect = [
185 + [],
186 + [{"output_type": "stream", "name": "stdout", "text": "cell 1\n"}],
187 + [{"output_type": "stream", "name": "stdout", "text": "cell 2\n"}],
188 + ]
189 +
190 + with patch("builtins.print"), pytest.raises(SystemExit) as error:
191 + main()
192 +
193 + assert error.value.code == 0
194 +
195 + output_nb_path = self.nb_path.replace(".ipynb", "_output.ipynb")
196 + self.assertTrue(os.path.exists(output_nb_path))
197 + with open(output_nb_path, "r", encoding="utf-8") as f:
198 + output_nb = nbformat.read(f, as_version=4)
199 +
200 + self.assertEqual(len(output_nb.cells), 3)
201 + # cell 1 outputs
202 + self.assertEqual(len(output_nb.cells[0].outputs), 1)
203 + self.assertEqual(output_nb.cells[0].outputs[0].text, "cell 1\n")
204 + # cell 2 is markdown
205 + self.assertEqual(output_nb.cells[1].cell_type, "markdown")
206 + self.assertFalse(
207 + hasattr(output_nb.cells[1], "outputs")
208 + and len(output_nb.cells[1].outputs) > 0
209 + )
210 + # cell 3 outputs
211 + self.assertEqual(len(output_nb.cells[2].outputs), 1)
212 + self.assertEqual(output_nb.cells[2].outputs[0].text, "cell 2\n")
213 +
214 +
215 +if __name__ == "__main__":
216 + unittest.main()
tests/test_keep_alive.py new
+423
@@ -0,0 +1,423 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock, patch, PropertyMock
16 +
17 +import pytest
18 +import typer
19 +from colab_cli.client import ColabRequestError
20 +from colab_cli.state import SessionState
21 +from colab_cli.commands.session import new, stop, keep_alive
22 +
23 +
24 +def test_session_state_with_pid():
25 + s = SessionState(
26 + name="test",
27 + token="tok",
28 + url="http://",
29 + endpoint="end",
30 + keep_alive_pid=1234,
31 + )
32 + data = s.model_dump()
33 + assert data["keep_alive_pid"] == 1234
34 +
35 + s2 = SessionState(**data)
36 + assert s2.keep_alive_pid == 1234
37 +
38 +
39 +@patch("colab_cli.commands.session.spawn_keep_alive")
40 +def test_new_spawns_keep_alive(mock_spawn, mock_common_state):
41 + # mock_common_state is automatically provided by conftest.py
42 + mock_common_state.client.assign.return_value = MagicMock(
43 + endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
44 + )
45 + mock_spawn.return_value = 9999
46 +
47 + new(session="test-sess")
48 +
49 + assert mock_spawn.called
50 + # Endpoint and session name are positional; auth provider is propagated
51 + # so the detached daemon uses the same provider as the parent (otherwise
52 + # the daemon falls back to Typer's --auth=oauth2 default and silently
53 + # uses the wrong auth backend — verified live 2026-04-30).
54 + assert mock_spawn.call_args.args == ("e1", "test-sess")
55 + assert "auth_provider" in mock_spawn.call_args.kwargs
56 +
57 + # Verify PID is saved in state
58 + assert mock_common_state.store.add.called
59 + state_saved = mock_common_state.store.add.call_args[0][0]
60 + assert state_saved.keep_alive_pid == 9999
61 +
62 +
63 +def test_spawn_keep_alive_command_includes_auth_flag(mocker):
64 + """spawn_keep_alive() must propagate `--auth=<provider>` as a global flag
65 + BEFORE the `keep-alive` subcommand name. Without this, the detached child
66 + falls back to the Typer default. Pin the exact arg ordering.
67 + """
68 + from colab_cli.auth import AuthProvider
69 + from colab_cli.commands.session import spawn_keep_alive
70 +
71 + mock_popen = mocker.patch("colab_cli.commands.session.subprocess.Popen")
72 + mock_popen.return_value.pid = 12345
73 +
74 + spawn_keep_alive("ep1", "sess1", auth_provider=AuthProvider.ADC)
75 +
76 + cmd = mock_popen.call_args.args[0]
77 + # Global flags must come before the subcommand name in Typer.
78 + assert "--auth=adc" in cmd
79 + auth_idx = cmd.index("--auth=adc")
80 + keep_alive_idx = cmd.index("keep-alive")
81 + assert auth_idx < keep_alive_idx, f"--auth must precede 'keep-alive' but got: {cmd}"
82 + # Endpoint and session_name must follow `keep-alive` in order.
83 + assert cmd[keep_alive_idx + 1] == "ep1"
84 + assert cmd[keep_alive_idx + 2] == "sess1"
85 +
86 +
87 +def test_spawn_keep_alive_command_includes_config_path(mocker):
88 + """spawn_keep_alive() must propagate `--config <path>` as a global flag
89 + so the daemon reads the same session state file as the parent. Without
90 + this, a parent invoked with `--config /tmp/foo/sessions.json` writes
91 + there but the daemon reads the default `~/.config/colab-cli/sessions.json`,
92 + finds no session, and exits with `reason=session_not_found`. Discovered
93 + while running the soak integration test 2026-04-30.
94 + """
95 + from colab_cli.commands.session import spawn_keep_alive
96 +
97 + mock_popen = mocker.patch("colab_cli.commands.session.subprocess.Popen")
98 + mock_popen.return_value.pid = 12345
99 +
100 + spawn_keep_alive("ep1", "sess1", config_path="/tmp/test/sessions.json")
101 +
102 + cmd = mock_popen.call_args.args[0]
103 + assert "--config" in cmd
104 + cfg_idx = cmd.index("--config")
105 + assert cmd[cfg_idx + 1] == "/tmp/test/sessions.json"
106 + keep_alive_idx = cmd.index("keep-alive")
107 + assert cfg_idx < keep_alive_idx, (
108 + f"--config must precede 'keep-alive' but got: {cmd}"
109 + )
110 +
111 +
112 +def test_spawn_keep_alive_omits_optional_flags_when_none(mocker):
113 + """Backwards compat: callers that don't pass optional global flags get a
114 + command line without them (the daemon uses Typer defaults)."""
115 + from colab_cli.commands.session import spawn_keep_alive
116 +
117 + mock_popen = mocker.patch("colab_cli.commands.session.subprocess.Popen")
118 + mock_popen.return_value.pid = 12345
119 +
120 + spawn_keep_alive("ep1", "sess1")
121 +
122 + cmd = mock_popen.call_args.args[0]
123 + assert not any(c.startswith("--auth") for c in cmd)
124 + assert "--config" not in cmd
125 +
126 +
127 +@patch("colab_cli.commands.session.spawn_keep_alive")
128 +def test_new_runs_keep_alive_preflight(mock_spawn, mock_common_state):
129 + """`colab new` should pre-flight the keep-alive RPC before persisting the
130 + session, so missing-scope failures are surfaced immediately rather than
131 + silently after ~2 minutes."""
132 + mock_common_state.client.assign.return_value = MagicMock(
133 + endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
134 + )
135 + mock_spawn.return_value = 9999
136 +
137 + new(session="test-sess")
138 +
139 + mock_common_state.client.keep_alive_assignment.assert_called_once_with("e1")
140 +
141 +
142 +@patch("colab_cli.commands.session.spawn_keep_alive")
143 +def test_new_aborts_on_missing_scope(mock_spawn, mock_common_state):
144 + """A 403 SCOPE_NOT_PERMITTED on pre-flight should:
145 + - print actionable remediation,
146 + - unassign the VM (so we don't leak a billable assignment),
147 + - exit non-zero,
148 + - and NOT spawn the keep-alive daemon or persist the session.
149 + """
150 + mock_common_state.client.assign.return_value = MagicMock(
151 + endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
152 + )
153 + mock_response = MagicMock()
154 + mock_response.status_code = 403
155 + scope_error = ColabRequestError(
156 + "Forbidden",
157 + MagicMock(),
158 + mock_response,
159 + response_body=(
160 + '[7,"Request had insufficient authentication scopes.",[["type.'
161 + 'googleapis.com/google.rpc.DebugInfo",[null,"Authentication error: '
162 + "2; Error Details: {AuthType:7,ErrorCode:2,DebugInfo:gaia_mint_"
163 + 'exchange::SCOPE_NOT_PERMITTED}"]]]]'
164 + ),
165 + )
166 + mock_common_state.client.keep_alive_assignment.side_effect = scope_error
167 +
168 + with pytest.raises(typer.Exit) as excinfo:
169 + new(session="test-sess")
170 + assert excinfo.value.exit_code == 1
171 +
172 + # We unassigned the VM we just created.
173 + mock_common_state.client.unassign.assert_called_once_with("e1")
174 + # We did NOT spawn the keep-alive daemon.
175 + mock_spawn.assert_not_called()
176 + # We did NOT persist the session.
177 + mock_common_state.store.add.assert_not_called()
178 +
179 +
180 +@patch("colab_cli.commands.session.spawn_keep_alive")
181 +def test_new_tolerates_non_scope_preflight_error(mock_spawn, mock_common_state):
182 + """Non-scope errors (e.g. transient 5xx, 400 from a different cause) on
183 + pre-flight should NOT block session creation — the daemon will retry and
184 + log via the existing keep_alive_error path.
185 + """
186 + mock_common_state.client.assign.return_value = MagicMock(
187 + endpoint="e1", runtime_proxy_info=MagicMock(token="t1", url="u1")
188 + )
189 + mock_response = MagicMock()
190 + mock_response.status_code = 503
191 + mock_common_state.client.keep_alive_assignment.side_effect = ColabRequestError(
192 + "Service Unavailable",
193 + MagicMock(),
194 + mock_response,
195 + response_body="upstream timeout",
196 + )
197 + mock_spawn.return_value = 9999
198 +
199 + new(session="test-sess")
200 +
201 + # We did NOT unassign — the session is still usable.
202 + mock_common_state.client.unassign.assert_not_called()
203 + # Daemon spawned and session persisted. `store.add` is called twice in
204 + # the daemon-spawning path: once BEFORE spawn (so the daemon's initial
205 + # session-existence check doesn't race), and once AFTER to record the
206 + # keep_alive_pid. Final state must include the PID.
207 + mock_spawn.assert_called_once()
208 + assert mock_common_state.store.add.call_count == 2
209 + final_state = mock_common_state.store.add.call_args.args[0]
210 + assert final_state.keep_alive_pid == 9999
211 +
212 +
213 +@patch("colab_cli.common.kill_process")
214 +def test_stop_kills_keep_alive(mock_kill, mock_common_state):
215 + mock_common_state.resolve_session.return_value = "test-sess"
216 + mock_common_state.store.get.return_value = SessionState(
217 + name="test-sess", token="t1", url="u1", endpoint="e1", keep_alive_pid=9999
218 + )
219 +
220 + stop(session="test-sess")
221 +
222 + mock_kill.assert_called_once_with(9999)
223 +
224 +
225 +def test_keep_alive_loop_basic(mock_common_state):
226 + mock_common_state.store.get.return_value = SessionState(
227 + name="test", token="t", url="u", endpoint="e1"
228 + )
229 +
230 + with (
231 + patch("time.sleep", side_effect=InterruptedError),
232 + patch("time.time", side_effect=[0, 100]),
233 + ):
234 + with pytest.raises(InterruptedError):
235 + keep_alive("e1", "test")
236 +
237 + mock_common_state.client.keep_alive_assignment.assert_called_once_with("e1")
238 +
239 +
240 +def test_keep_alive_exits_on_consecutive_4xx(mock_common_state):
241 + # Mock response for 404 error
242 + mock_response = MagicMock()
243 + mock_response.status_code = 404
244 + error = ColabRequestError("Not Found", MagicMock(), mock_response)
245 +
246 + mock_common_state.store.get.return_value = SessionState(
247 + name="test", token="t", url="u", endpoint="e1"
248 + )
249 + mock_common_state.client.keep_alive_assignment.side_effect = error
250 +
251 + with (
252 + patch("time.sleep") as mock_sleep,
253 + patch("time.time", side_effect=range(0, 10000, 60)),
254 + ):
255 + # It should exit after 2 calls to ping (consecutive 4xx)
256 + # We'll use side_effect on mock_sleep to detect if it loops too much
257 + mock_sleep.side_effect = [None, None, Exception("LoopTooLong")]
258 +
259 + try:
260 + keep_alive("e1", "test")
261 + except Exception as e:
262 + if str(e) == "LoopTooLong":
263 + pytest.fail("Keep alive loop did not exit after consecutive 4xx")
264 + raise
265 +
266 + assert mock_common_state.client.keep_alive_assignment.call_count == 2
267 +
268 +
269 +def test_keep_alive_resets_on_success(mock_common_state):
270 + # Mock response for 404 error
271 + mock_response_404 = MagicMock()
272 + mock_response_404.status_code = 404
273 + error_404 = ColabRequestError("Not Found", MagicMock(), mock_response_404)
274 +
275 + mock_common_state.store.get.return_value = SessionState(
276 + name="test", token="t", url="u", endpoint="e1"
277 + )
278 +
279 + # ping sequence: 404, success, 404, 404
280 + mock_common_state.client.keep_alive_assignment.side_effect = [
281 + error_404,
282 + None,
283 + error_404,
284 + error_404,
285 + ]
286 +
287 + with (
288 + patch("time.sleep") as mock_sleep,
289 + patch("time.time", side_effect=range(0, 10000, 60)),
290 + ):
291 + # We need to make sure it doesn't loop forever
292 + mock_sleep.side_effect = [None, None, None, Exception("StopLoop")]
293 +
294 + try:
295 + keep_alive("e1", "test")
296 + except Exception as e:
297 + if str(e) != "StopLoop":
298 + raise
299 +
300 +
301 +@patch("colab_cli.common.kill_process")
302 +def test_sync_sessions_handles_lost_vm(mock_kill, mock_common_state):
303 + # Server returns empty list (indicating VM is gone)
304 + mock_common_state.client.list_assignments.return_value = []
305 +
306 + lost_session = SessionState(
307 + name="lost-sess", token="t1", url="u1", endpoint="e1", keep_alive_pid=7777
308 + )
309 + # Local session has a keep_alive_pid
310 + mock_common_state.store.list.return_value = {"lost-sess": lost_session}
311 + # Ensure store.get returns the session too
312 + mock_common_state.store.get.return_value = lost_session
313 +
314 + from colab_cli.common import State
315 +
316 + real_state = State()
317 +
318 + with (
319 + patch.object(State, "store", new_callable=PropertyMock) as mock_store_prop,
320 + patch.object(State, "client", new_callable=PropertyMock) as mock_client_prop,
321 + patch.object(State, "history", new_callable=PropertyMock) as mock_hist_prop,
322 + ):
323 + mock_store_prop.return_value = mock_common_state.store
324 + mock_client_prop.return_value = mock_common_state.client
325 + mock_hist_prop.return_value = mock_common_state.history
326 +
327 + real_state.sync_sessions()
328 +
329 + mock_kill.assert_called_with(7777)
330 +
331 +
332 +def test_keep_alive_logging(mock_common_state):
333 + # Mock successful run that eventually hits time limit
334 + mock_common_state.store.get.return_value = SessionState(
335 + name="test", token="t", url="u", endpoint="e1"
336 + )
337 +
338 + # time.time() is called: start_time, loop-condition (force exit),
339 + # and once at the end for duration_seconds calculation.
340 + with (
341 + patch("time.time", side_effect=[0, 24 * 3600 + 1, 24 * 3600 + 1]),
342 + patch("time.sleep"),
343 + ):
344 + keep_alive("e1", "test")
345 +
346 + # Verify logging
347 + log_calls = mock_common_state.history.log_event.call_args_list
348 + started = [c for c in log_calls if c.args[1] == "keep_alive_started"]
349 + assert started, "expected keep_alive_started event"
350 + assert started[0].args[2]["endpoint"] == "e1"
351 + assert "pid" in started[0].args[2]
352 +
353 + stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
354 + assert stopped, "expected keep_alive_stopped event"
355 + payload = stopped[0].args[2]
356 + assert payload["reason"] == "time_limit_reached"
357 + assert "iterations" in payload
358 + assert "duration_seconds" in payload
359 +
360 +
361 +def test_keep_alive_logging_session_gone(mock_common_state):
362 + # Session not found in store
363 + mock_common_state.store.get.return_value = None
364 +
365 + with patch("time.time", return_value=0), patch("time.sleep"):
366 + keep_alive("e1", "test")
367 +
368 + log_calls = mock_common_state.history.log_event.call_args_list
369 + stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
370 + assert stopped, "expected keep_alive_stopped event"
371 + payload = stopped[0].args[2]
372 + assert payload["reason"] == "session_not_found"
373 + assert payload["iterations"] == 1
374 +
375 +
376 +def test_keep_alive_logs_endpoint_mismatch_details(mock_common_state):
377 + # Session exists but endpoint has changed.
378 + mock_common_state.store.get.return_value = SessionState(
379 + name="test", token="t", url="u", endpoint="e2-new"
380 + )
381 +
382 + with patch("time.time", return_value=0), patch("time.sleep"):
383 + keep_alive("e1-old", "test")
384 +
385 + log_calls = mock_common_state.history.log_event.call_args_list
386 + stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
387 + assert stopped, "expected keep_alive_stopped event"
388 + payload = stopped[0].args[2]
389 + assert payload["reason"] == "endpoint_mismatch"
390 + assert payload["expected_endpoint"] == "e1-old"
391 + assert payload["actual_endpoint"] == "e2-new"
392 +
393 +
394 +def test_keep_alive_logs_error_events_and_last_error(mock_common_state):
395 + # Two consecutive 4xx errors -> exit, with per-error events + last_error in stop.
396 + mock_response = MagicMock()
397 + mock_response.status_code = 404
398 + error = ColabRequestError("Not Found", MagicMock(), mock_response)
399 +
400 + mock_common_state.store.get.return_value = SessionState(
401 + name="test", token="t", url="u", endpoint="e1"
402 + )
403 + mock_common_state.client.keep_alive_assignment.side_effect = error
404 +
405 + with (
406 + patch("time.sleep"),
407 + patch("time.time", side_effect=range(0, 10000, 60)),
408 + ):
409 + keep_alive("e1", "test")
410 +
411 + log_calls = mock_common_state.history.log_event.call_args_list
412 +
413 + errors = [c for c in log_calls if c.args[1] == "keep_alive_error"]
414 + assert len(errors) == 2, "expected one keep_alive_error per failed ping"
415 + assert errors[0].args[2]["status_code"] == 404
416 + assert errors[0].args[2]["error_type"] == "ColabRequestError"
417 +
418 + stopped = [c for c in log_calls if c.args[1] == "keep_alive_stopped"]
419 + assert stopped, "expected keep_alive_stopped event"
420 + payload = stopped[0].args[2]
421 + assert payload["reason"] == "consecutive_4xx_errors"
422 + assert payload["last_error"]["status_code"] == 404
423 + assert payload["last_error"]["error_type"] == "ColabRequestError"
tests/test_log_export.py new
+109
@@ -0,0 +1,109 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import json
16 +import os
17 +import shutil
18 +import sys
19 +import tempfile
20 +import unittest
21 +import pytest
22 +from unittest.mock import patch
23 +from colab_cli.cli import main
24 +from colab_cli.history import HistoryLogger
25 +
26 +
27 +class TestLogExport(unittest.TestCase):
28 + def setUp(self):
29 + self.temp_dir = tempfile.mkdtemp()
30 + self.history_dir = os.path.join(self.temp_dir, "history")
31 + os.makedirs(self.history_dir)
32 + self.session_name = "test-export"
33 + self.log_path = os.path.join(self.history_dir, f"{self.session_name}.jsonl")
34 +
35 + # Sample events
36 + events = [
37 + {
38 + "timestamp": "2026-03-23T12:00:00.000000+00:00",
39 + "event_type": "session_created",
40 + "endpoint": "ep1",
41 + "accelerator": "NONE",
42 + },
43 + {
44 + "timestamp": "2026-03-23T12:01:00.000000+00:00",
45 + "event_type": "execution",
46 + "code": "print(1)",
47 + "outputs": [{"text": "1\n"}],
48 + },
49 + {
50 + "timestamp": "2026-03-23T12:02:00.000000+00:00",
51 + "event_type": "file_operation",
52 + "op": "ls",
53 + "path": "content",
54 + },
55 + ]
56 + with open(self.log_path, "w", encoding="utf-8") as f:
57 + for event in events:
58 + f.write(json.dumps(event) + "\n")
59 +
60 + def tearDown(self):
61 + shutil.rmtree(self.temp_dir)
62 + if os.path.exists(f"{self.session_name}.ipynb"):
63 + os.remove(f"{self.session_name}.ipynb")
64 +
65 + @patch("colab_cli.commands.utility.state")
66 + def test_log_export(self, mock_state):
67 + # Setup mocks to return our test events
68 +
69 + # Real HistoryLogger to read our temp log
70 + real_history = HistoryLogger(log_dir=self.history_dir)
71 + mock_state.history.get_history.side_effect = real_history.get_history
72 +
73 + with patch.object(
74 + sys,
75 + "argv",
76 + [
77 + "colab",
78 + "log",
79 + "-s",
80 + self.session_name,
81 + "-o",
82 + f"{self.session_name}.ipynb",
83 + ],
84 + ):
85 + with pytest.raises(SystemExit) as exitinfo:
86 + main()
87 + self.assertEqual(exitinfo.value.code, 0)
88 +
89 + output_file = f"{self.session_name}.ipynb"
90 + self.assertTrue(os.path.exists(output_file))
91 +
92 + with open(output_file, "r") as f:
93 + nb = json.load(f)
94 + # Should have title, session_created md, execution code, and file_op md cells
95 + # Total cells: 4 (title, session_created, execution, file_op)
96 + self.assertEqual(len(nb["cells"]), 4)
97 + self.assertEqual(nb["cells"][2]["cell_type"], "code")
98 + source = nb["cells"][2]["source"]
99 + if isinstance(source, list):
100 + source = "".join(source)
101 + self.assertEqual(source, "print(1)")
102 + output_text = nb["cells"][2]["outputs"][0]["text"]
103 + if isinstance(output_text, list):
104 + output_text = "".join(output_text)
105 + self.assertEqual(output_text, "1\n")
106 +
107 +
108 +if __name__ == "__main__":
109 + unittest.main()
tests/test_pay.py new
+30
@@ -0,0 +1,30 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import pytest
16 +import sys
17 +from unittest.mock import patch
18 +from colab_cli.cli import main
19 +
20 +
21 +def test_cli_pay(mock_common_state):
22 + with patch.object(sys, "argv", ["colab", "pay"]):
23 + with patch("webbrowser.open") as mock_open:
24 + with pytest.raises(SystemExit) as error:
25 + main()
26 +
27 + assert error.value.code == 0
28 + mock_open.assert_called_once_with(
29 + "https://colab.research.google.com/signup"
30 + )
tests/test_repl.py new
+268
@@ -0,0 +1,268 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock, patch, ANY
16 +
17 +import pytest
18 +from typer.testing import CliRunner
19 +
20 +from colab_cli.cli import app
21 +from colab_cli.repl import ColabREPL
22 +
23 +runner = CliRunner()
24 +
25 +
26 +@pytest.fixture
27 +def mock_store(mock_common_state):
28 + return mock_common_state.store
29 +
30 +
31 +@pytest.fixture
32 +def mock_runtime_class(mocker):
33 + # Patch it in the command module where it's used
34 + return mocker.patch("colab_cli.commands.execution.ColabRuntime")
35 +
36 +
37 +@patch("colab_cli.repl.handle_image")
38 +def test_repl_display_output(mock_handle_image, capsys):
39 + runtime = MagicMock()
40 + repl_inst = ColabREPL(runtime)
41 +
42 + outputs = [
43 + {"text": "hello"},
44 + {"data": {"image/png": "png_data", "text/plain": "<Figure size>"}},
45 + {"data": {"image/jpeg": "jpeg_data", "text/plain": "other_text"}},
46 + {"output_type": "error", "ename": "ValueError", "evalue": "bad"},
47 + {"output_type": "error", "traceback": ["line1\n", "line2\n"]},
48 + ]
49 +
50 + for o in outputs:
51 + repl_inst.display_output(o)
52 +
53 + mock_handle_image.assert_any_call("png_data", "image/png", target_path=None)
54 + mock_handle_image.assert_any_call("jpeg_data", "image/jpeg", target_path=None)
55 +
56 + captured = capsys.readouterr()
57 + assert "hello" in captured.out
58 + assert "other_text" in captured.out
59 + assert "ValueError: bad" in captured.out
60 + assert "line1\nline2\n" in captured.out
61 +
62 +
63 +def test_repl_execute(mock_store, mock_common_state):
64 + runtime = MagicMock()
65 +
66 + mock_session = MagicMock()
67 + mock_store.get.return_value = mock_session
68 +
69 + def mock_execute_code(code, output_hook=None, **kwargs):
70 + o = {"text": "done"}
71 + if output_hook:
72 + output_hook(o)
73 + return [o]
74 +
75 + runtime.execute_code.side_effect = mock_execute_code
76 + repl_inst = ColabREPL(runtime, session_name="s1")
77 +
78 + with patch.object(repl_inst, "display_output") as mock_display:
79 + repl_inst.execute("print(1)")
80 + mock_display.assert_called_once_with({"text": "done"})
81 + assert repl_inst.repl_history[0]["input"] == "print(1)"
82 +
83 + assert mock_session.last_execution[0] == "REPL"
84 + assert mock_session.last_execution[1] is None
85 + assert mock_session.last_execution[2] is not None
86 + mock_store.add.assert_called_with(mock_session)
87 +
88 +
89 +def test_cli_repl_interactive(
90 + mock_runtime_class, mock_store, mock_common_state, mocker
91 +):
92 + mock_session = MagicMock()
93 + mock_session.name = "s1"
94 + mock_session.url = "http://url"
95 + mock_session.token = "token"
96 + mock_session.kernel_id = None
97 + mock_session.session_id = None
98 + mock_store.get.return_value = mock_session
99 +
100 + mock_common_state.resolve_session.return_value = "s1"
101 +
102 + # Mock is_stdin_tty to True to follow the interactive path
103 + mocker.patch("colab_cli.commands.execution.is_stdin_tty", return_value=True)
104 +
105 + # Simulate TTY for interactive REPL
106 + with patch("colab_cli.repl.ColabREPL") as mock_repl_class:
107 + # Mock run to prevent infinite loop or errors
108 + mock_repl_class.return_value.run.return_value = None
109 + runner.invoke(app, ["repl", "-s", "s1"])
110 + assert mock_repl_class.called
111 +
112 +
113 +def test_cli_repl_piped(mock_runtime_class, mock_store, mock_common_state):
114 + mock_session = MagicMock()
115 + mock_session.name = "s1"
116 + mock_session.url = "http://url"
117 + mock_session.token = "token"
118 + mock_session.kernel_id = None
119 + mock_session.session_id = None
120 + mock_store.get.return_value = mock_session
121 +
122 + mock_runtime = mock_runtime_class.return_value
123 + mock_runtime.execute_code.return_value = [{"text": "done piped"}]
124 +
125 + mock_common_state.resolve_session.return_value = "s1"
126 + result = runner.invoke(app, ["repl", "-s", "s1"], input="print(1)")
127 + assert result.exit_code == 0
128 + assert mock_session.last_execution[0] == "stdin"
129 + assert mock_session.last_execution[2] is not None
130 + mock_store.add.assert_called_with(mock_session)
131 + mock_runtime.execute_code.assert_any_call("print(1)", output_hook=ANY)
132 +
133 +
134 +def test_cli_repl_missing_session(mock_common_state):
135 + mock_common_state.resolve_session.side_effect = SystemExit(1)
136 + result = runner.invoke(app, ["repl", "-s", "missing"])
137 + assert result.exit_code == 1
138 +
139 +
140 +def test_cli_repl_piped_empty(mock_runtime_class, mock_store, mock_common_state):
141 + mock_session = MagicMock()
142 + mock_session.name = "s1"
143 + mock_session.url = "http://url"
144 + mock_session.token = "token"
145 + mock_session.kernel_id = None
146 + mock_session.session_id = None
147 + mock_store.get.return_value = mock_session
148 +
149 + mock_common_state.resolve_session.return_value = "s1"
150 + result = runner.invoke(app, ["repl", "-s", "s1"], input=" \n ")
151 + assert result.exit_code == 0
152 +
153 +
154 +def test_repl_print_info_error(capsys):
155 + repl_inst = ColabREPL(MagicMock())
156 + repl_inst.print_info("info_msg")
157 + repl_inst.print_error("err_msg")
158 +
159 +
160 +@patch("colab_cli.repl.handle_image")
161 +def test_repl_display_output_image_suppress_text(mock_handle_image, capsys):
162 + repl_inst = ColabREPL(MagicMock())
163 + output = {"data": {"image/png": "png_data", "text/plain": "<Figure size>"}}
164 + repl_inst.display_output(output)
165 + mock_handle_image.assert_called_once_with("png_data", "image/png", target_path=None)
166 +
167 + captured = capsys.readouterr()
168 + assert "<Figure size>" not in captured.out
169 +
170 +
171 +def test_repl_execute_error(capsys):
172 + runtime = MagicMock()
173 + runtime.execute_code.side_effect = Exception("Kernel ded")
174 + repl_inst = ColabREPL(runtime)
175 +
176 + repl_inst.execute("print(1)")
177 +
178 + captured = capsys.readouterr()
179 + assert "Kernel ded" in captured.out
180 +
181 +
182 +def test_repl_run_quit_aliases(mocker):
183 + runtime = MagicMock()
184 + repl_inst = ColabREPL(runtime)
185 + repl_inst.session = MagicMock()
186 +
187 + # Test /quit
188 + repl_inst.session.prompt.side_effect = ["/quit"]
189 + repl_inst.run()
190 + assert runtime.stop.called
191 +
192 + # Test quit()
193 + runtime.stop.reset_mock()
194 + repl_inst.session.prompt.side_effect = ["quit()"]
195 + repl_inst.run()
196 + assert runtime.stop.called
197 +
198 + # Test exit()
199 + runtime.stop.reset_mock()
200 + repl_inst.session.prompt.side_effect = ["exit()"]
201 + repl_inst.run()
202 + assert runtime.stop.called
203 +
204 +
205 +def test_repl_run_misc_inputs(mocker, capsys):
206 + runtime = MagicMock()
207 + repl_inst = ColabREPL(runtime)
208 + repl_inst.session = MagicMock()
209 +
210 + # None result, empty string, then exit()
211 + repl_inst.session.prompt.side_effect = [None, " ", "exit()"]
212 + repl_inst.run()
213 + assert runtime.stop.called
214 +
215 +
216 +def test_repl_run_exceptions(mocker, capsys):
217 + runtime = MagicMock()
218 + repl_inst = ColabREPL(runtime)
219 + repl_inst.session = MagicMock()
220 +
221 + # EOFError
222 + repl_inst.session.prompt.side_effect = [EOFError()]
223 + repl_inst.run()
224 + assert "Goodbye!" in capsys.readouterr().out
225 +
226 + # KeyboardInterrupt
227 + repl_inst.session.prompt.side_effect = [KeyboardInterrupt(), "exit()"]
228 + repl_inst.run()
229 +
230 + # Generic Exception
231 + repl_inst.session.prompt.side_effect = [Exception("ouch"), "exit()"]
232 + repl_inst.run()
233 + assert "REPL Error: ouch" in capsys.readouterr().out
234 +
235 +
236 +def test_repl_run_executes_code(mocker):
237 + runtime = MagicMock()
238 + repl_inst = ColabREPL(runtime)
239 + repl_inst.session = MagicMock()
240 +
241 + with patch.object(repl_inst, "execute") as mock_execute:
242 + repl_inst.session.prompt.side_effect = ["print(1)", "/quit"]
243 + repl_inst.run()
244 + mock_execute.assert_called_once_with("print(1)")
245 +
246 +
247 +def test_repl_execute_with_history(mock_store):
248 + runtime = MagicMock()
249 + history_logger = MagicMock()
250 + repl_inst = ColabREPL(runtime, session_name="s1", history_logger=history_logger)
251 +
252 + repl_inst.execute("print(1)")
253 + assert history_logger.log_event.called
254 +
255 +
256 +def test_repl_key_bindings(mocker):
257 + runtime = MagicMock()
258 + repl_inst = ColabREPL(runtime)
259 +
260 + # Trigger 'enter' binding (which is mapped to c-m in prompt_toolkit)
261 + mock_event = MagicMock()
262 + repl_inst.kb.get_bindings_for_keys(("c-m",))[0].handler(mock_event)
263 + assert mock_event.current_buffer.validate_and_handle.called
264 +
265 + # Trigger 'c-j' binding
266 + mock_event = MagicMock()
267 + repl_inst.kb.get_bindings_for_keys(("c-j",))[0].handler(mock_event)
268 + mock_event.current_buffer.insert_text.assert_called_with("\n")
tests/test_resolution_logic.py new
+119
@@ -0,0 +1,119 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock, patch
16 +import pytest
17 +import typer
18 +from colab_cli.auth import AuthProvider
19 +from colab_cli.common import State
20 +
21 +
22 +def test_resolve_session_no_local_sessions():
23 + state = State()
24 + state._store = MagicMock()
25 + state._store.list.return_value = {}
26 +
27 + with patch("typer.echo") as mock_echo:
28 + with pytest.raises(typer.Exit):
29 + state.resolve_session(None)
30 + mock_echo.assert_any_call(
31 + "[colab] Error: No active sessions found. Create one with 'colab new'."
32 + )
33 +
34 +
35 +def test_resolve_session_with_local_but_none_on_server():
36 + state = State()
37 + state._store = MagicMock()
38 + # Local session exists
39 + mock_session = MagicMock()
40 + mock_session.endpoint = "e1"
41 + state._store.list.return_value = {"s1": mock_session}
42 +
43 + # But server says no assignments
44 + state._client = MagicMock()
45 + state._client.list_assignments.return_value = []
46 +
47 + # Mock history and store.remove
48 + state._history = MagicMock()
49 +
50 + with patch("typer.echo") as mock_echo:
51 + with pytest.raises(typer.Exit):
52 + state.resolve_session(None)
53 + mock_echo.assert_any_call("[colab] Pruned 1 stale local session(s).")
54 + mock_echo.assert_any_call(
55 + "[colab] Error: No active sessions found. Create one with 'colab new'."
56 + )
57 +
58 + state._store.remove.assert_called_with("s1")
59 +
60 +
61 +def test_sync_sessions_avoids_client_if_no_local():
62 + state = State()
63 + state._store = MagicMock()
64 + state._store.list.return_value = {}
65 +
66 + # We want to verify that self.client is NOT accessed if store.list() is empty
67 + # unless we explicitly call sync_sessions.
68 + # Actually, in my current implementation of sync_sessions, I still call self.client.list_assignments()
69 + # to support 'colab sessions' but I wrap it in a try-except.
70 +
71 + with patch.object(State, "client", new_callable=MagicMock) as mock_client_prop:
72 + state.sync_sessions()
73 + # My implementation DOES call it to return assignments.
74 + mock_client_prop.list_assignments.assert_called_once()
75 +
76 +
77 +def test_resolve_session_avoids_sync_if_no_local():
78 + state = State()
79 + state._store = MagicMock()
80 + state._store.list.return_value = {}
81 +
82 + with patch.object(State, "sync_sessions") as mock_sync:
83 + with pytest.raises(typer.Exit):
84 + state.resolve_session(None)
85 + mock_sync.assert_not_called()
86 +
87 +
88 +def test_state_client_auth_flag_propagation():
89 + state = State()
90 + state.auth_provider = AuthProvider.OAUTH2
91 +
92 + with patch("colab_cli.common.get_credentials") as mock_get_creds:
93 + with patch("colab_cli.common.Client"):
94 + _ = state.client
95 + mock_get_creds.assert_called_once()
96 + args, kwargs = mock_get_creds.call_args
97 + assert kwargs["provider"] is AuthProvider.OAUTH2
98 +
99 +
100 +def test_state_client_auth_provider_default_is_oauth2():
101 + state = State()
102 + assert state.auth_provider is AuthProvider.OAUTH2
103 +
104 + with patch("colab_cli.common.get_credentials") as mock_get_creds:
105 + with patch("colab_cli.common.Client"):
106 + _ = state.client
107 + args, kwargs = mock_get_creds.call_args
108 + assert kwargs["provider"] is AuthProvider.OAUTH2
109 +
110 +
111 +def test_state_client_auth_provider_adc():
112 + state = State()
113 + state.auth_provider = AuthProvider.ADC
114 +
115 + with patch("colab_cli.common.get_credentials") as mock_get_creds:
116 + with patch("colab_cli.common.Client"):
117 + _ = state.client
118 + args, kwargs = mock_get_creds.call_args
119 + assert kwargs["provider"] is AuthProvider.ADC
tests/test_runtime.py new
+120
@@ -0,0 +1,120 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock, patch
16 +
17 +import jupyter_kernel_client
18 +
19 +from colab_cli.runtime import ColabRuntime
20 +
21 +
22 +@patch("colab_cli.runtime.jupyter_kernel_client.KernelClient")
23 +def test_colab_runtime_kernel_client(mock_kc_cls):
24 + mock_kc = mock_kc_cls.return_value
25 +
26 + runtime = ColabRuntime("http://url", "token123")
27 +
28 + assert runtime._kernel_client is None
29 +
30 + kc = runtime.kernel_client
31 +
32 + mock_kc_cls.assert_called_once_with(
33 + server_url="http://url",
34 + token="token123",
35 + kernel_id=None,
36 + client_kwargs={
37 + "subprotocol": jupyter_kernel_client.JupyterSubprotocol.DEFAULT,
38 + "extra_params": {"colab-runtime-proxy-token": "token123"},
39 + },
40 + headers={
41 + "X-Colab-Client-Agent": "colab-cli",
42 + "X-Colab-Runtime-Proxy-Token": "token123",
43 + },
44 + )
45 + mock_kc.start.assert_called_once()
46 + assert kc == mock_kc
47 +
48 +
49 +def test_colab_runtime_execute_code():
50 + runtime = ColabRuntime("http://url", "token123")
51 + mock_kc = MagicMock()
52 + runtime._kernel_client = mock_kc
53 +
54 + # Test empty reply
55 + mock_kc.execute.return_value = {}
56 + assert runtime.execute_code("print(1)") == []
57 +
58 + # Test normal reply
59 + mock_kc.execute.return_value = {"outputs": [{"text": "1\n"}]}
60 + assert runtime.execute_code("print(1)") == [{"text": "1\n"}]
61 +
62 + # Test error status without error output
63 + mock_kc.execute.return_value = {
64 + "status": "error",
65 + "ename": "ValueError",
66 + "evalue": "bad",
67 + "outputs": [{"text": "partial"}],
68 + }
69 + outputs = runtime.execute_code("raise ValueError")
70 + assert len(outputs) == 2
71 + assert outputs[0] == {"text": "partial"}
72 + assert outputs[1] == {
73 + "output_type": "error",
74 + "ename": "ValueError",
75 + "evalue": "bad",
76 + "traceback": [],
77 + }
78 +
79 +
80 +def test_colab_runtime_stop():
81 + runtime = ColabRuntime("http://url", "token123")
82 + mock_kc = MagicMock()
83 + runtime._kernel_client = mock_kc
84 +
85 + runtime.stop()
86 + mock_kc._manager.client.stop_channels.assert_called_once()
87 +
88 +
89 +def test_colab_runtime_stop_exception(caplog):
90 + runtime = ColabRuntime("http://url", "token123")
91 + mock_kc = MagicMock()
92 + mock_kc._manager.client.stop_channels.side_effect = Exception("Stop failed")
93 + runtime._kernel_client = mock_kc
94 +
95 + runtime.stop() # Should not raise
96 + assert "Error stopping kernel client" in caplog.text
97 +
98 +
99 +def test_colab_runtime_stdin_logging():
100 + mock_history = MagicMock()
101 + runtime = ColabRuntime(
102 + "http://url", "token", session_name="test-s", history=mock_history
103 + )
104 + mock_kc = MagicMock()
105 + runtime._kernel_client = mock_kc
106 +
107 + mock_kc.execute.side_effect = lambda code, allow_stdin=False, stdin_hook=None: {
108 + "outputs": [{"text": stdin_hook("Enter something: ")}]
109 + }
110 +
111 + with patch("colab_cli.runtime.input", return_value="user input"):
112 + outputs = runtime.execute_code("code", allow_stdin=True)
113 +
114 + assert outputs == [{"text": "user input"}]
115 + mock_history.log_event.assert_any_call(
116 + "test-s", "stdin_request", {"prompt": "Enter something: "}
117 + )
118 + mock_history.log_event.assert_any_call(
119 + "test-s", "input_reply", {"value": "user input"}
120 + )
tests/test_state.py new
+123
@@ -0,0 +1,123 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import os
16 +import pytest
17 +import tempfile
18 +import threading
19 +from datetime import datetime
20 +from colab_cli.state import StateStore, SessionState, SettingsStore, Settings
21 +
22 +
23 +@pytest.fixture
24 +def temp_config():
25 + with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
26 + path = f.name
27 + yield path
28 + if os.path.exists(path):
29 + os.remove(path)
30 +
31 +
32 +def test_state_store_add_get(temp_config):
33 + store = StateStore(temp_config)
34 + state = SessionState(
35 + name="test-session",
36 + token="token123",
37 + url="http://localhost",
38 + endpoint="endpoint456",
39 + variant="TPU",
40 + accelerator="V5E1",
41 + )
42 + store.add(state)
43 +
44 + # Reload store
45 + new_store = StateStore(temp_config)
46 + loaded = new_store.get("test-session")
47 + assert loaded is not None
48 + assert loaded.name == "test-session"
49 + assert loaded.token == "token123"
50 + assert loaded.variant == "TPU"
51 +
52 +
53 +def test_state_store_remove(temp_config):
54 + store = StateStore(temp_config)
55 + state = SessionState(name="to-be-removed", token="tok", url="url", endpoint="end")
56 + store.add(state)
57 + assert store.get("to-be-removed") is not None
58 +
59 + store.remove("to-be-removed")
60 + assert store.get("to-be-removed") is None
61 +
62 + # Reload check
63 + new_store = StateStore(temp_config)
64 + assert new_store.get("to-be-removed") is None
65 +
66 +
67 +def test_state_store_list(temp_config):
68 + store = StateStore(temp_config)
69 + s1 = SessionState(name="s1", token="t1", url="u1", endpoint="e1")
70 + s2 = SessionState(name="s2", token="t2", url="u2", endpoint="e2")
71 + store.add(s1)
72 + store.add(s2)
73 +
74 + sessions = store.list()
75 + assert len(sessions) == 2
76 + assert "s1" in sessions
77 + assert "s2" in sessions
78 +
79 +
80 +def test_state_store_invalid_json(temp_config):
81 + with open(temp_config, "w") as f:
82 + f.write("invalid json")
83 +
84 + store = StateStore(temp_config)
85 + assert store.list() == {}
86 +
87 +
88 +def test_state_store_concurrency(temp_config):
89 + def add_sessions(start, count, path):
90 + store = StateStore(path)
91 + for i in range(start, start + count):
92 + s = SessionState(name=f"s{i}", token="t", url="u", endpoint="e")
93 + store.add(s)
94 +
95 + t1 = threading.Thread(target=add_sessions, args=(0, 50, temp_config))
96 + t2 = threading.Thread(target=add_sessions, args=(50, 50, temp_config))
97 +
98 + t1.start()
99 + t2.start()
100 + t1.join()
101 + t2.join()
102 +
103 + new_store = StateStore(temp_config)
104 + # This might pass or fail depending on luck without locking
105 + # But usually it fails with 100 iterations.
106 + assert len(new_store.list()) == 100
107 +
108 +
109 +def test_settings_store_defaults(temp_config):
110 + store = SettingsStore(temp_config)
111 + settings = store.load()
112 + assert settings.enable_update_check is True
113 + assert settings.last_check is None
114 +
115 +
116 +def test_settings_store_save_load(temp_config):
117 + store = SettingsStore(temp_config)
118 + settings = Settings(enable_update_check=False, last_check=datetime(2026, 1, 1))
119 + store.save(settings)
120 +
121 + loaded = SettingsStore(temp_config).load()
122 + assert loaded.enable_update_check is False
123 + assert loaded.last_check == datetime(2026, 1, 1)
tests/test_streaming.py new
+98
@@ -0,0 +1,98 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from unittest.mock import MagicMock
16 +from colab_cli.runtime import ColabRuntime
17 +
18 +
19 +def test_runtime_execute_code_streaming():
20 + runtime = ColabRuntime("http://url", "token")
21 + mock_client = MagicMock()
22 +
23 + # Mock the return value of execute_interactive (the raw reply)
24 + mock_client.execute_interactive.return_value = {"content": {"status": "ok"}}
25 +
26 + # Inject our mock client
27 + runtime._kernel_client = mock_client
28 +
29 + streamed_outputs = []
30 +
31 + def output_hook(o):
32 + streamed_outputs.append(o)
33 +
34 + code = "print(1); print(2)"
35 +
36 + # We need to simulate the execution where execute_interactive is called
37 + # and it calls our wrapped_output_hook.
38 + # Since wrapped_output_hook is defined inside execute_code, we have to
39 + # intercept the call to execute_interactive to get a reference to it.
40 +
41 + def side_effect(code, output_hook=None, **kwargs):
42 + # Simulate messages arriving
43 + msg1 = {
44 + "header": {"msg_type": "stream"},
45 + "content": {"name": "stdout", "text": "1\n"},
46 + }
47 + msg2 = {
48 + "header": {"msg_type": "stream"},
49 + "content": {"name": "stdout", "text": "2\n"},
50 + }
51 + if output_hook:
52 + output_hook(msg1)
53 + output_hook(msg2)
54 + return {"content": {"status": "ok"}}
55 +
56 + mock_client.execute_interactive.side_effect = side_effect
57 +
58 + outputs = runtime.execute_code(code, output_hook=output_hook)
59 +
60 + assert len(outputs) == 2
61 + assert outputs[0]["text"] == "1\n"
62 + assert outputs[1]["text"] == "2\n"
63 +
64 + # Verify streaming hook was called
65 + assert len(streamed_outputs) == 2
66 + assert streamed_outputs[0]["text"] == "1\n"
67 + assert streamed_outputs[1]["text"] == "2\n"
68 +
69 +
70 +def test_runtime_execute_code_streaming_error_synthesis():
71 + runtime = ColabRuntime("http://url", "token")
72 + mock_client = MagicMock()
73 +
74 + # Simulate an error reply but NO error output message
75 + mock_client.execute_interactive.return_value = {
76 + "content": {
77 + "status": "error",
78 + "ename": "RuntimeError",
79 + "evalue": "something went wrong",
80 + "traceback": ["tb line 1"],
81 + }
82 + }
83 + runtime._kernel_client = mock_client
84 +
85 + streamed_outputs = []
86 + outputs = runtime.execute_code(
87 + "fail()", output_hook=lambda o: streamed_outputs.append(o)
88 + )
89 +
90 + # Final outputs should include synthesized error
91 + assert len(outputs) == 1
92 + assert outputs[0]["output_type"] == "error"
93 + assert outputs[0]["ename"] == "RuntimeError"
94 +
95 + # Note: synthesized error is added AFTER execute_interactive returns,
96 + # so it won't be in streamed_outputs unless we specifically add logic for it.
97 + # Currently it's only in the returned list.
98 + assert len(streamed_outputs) == 0
tests/test_update.py new
+462
@@ -0,0 +1,462 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import json
16 +from datetime import datetime, timedelta, timezone
17 +
18 +import pytest
19 +from typer.testing import CliRunner
20 +
21 +from colab_cli.cli import app
22 +from colab_cli.state import Settings
23 +
24 +runner = CliRunner()
25 +
26 +
27 +# ---------- Shared fixtures ----------------------------------------------
28 +
29 +
30 +@pytest.fixture
31 +def fake_settings(mocker):
32 + """Return a builder that mocks the SettingsStore with caller-provided overrides.
33 +
34 + The mocked ``load()`` returns a fresh copy of the seeded ``Settings`` on
35 + each call, mirroring real on-disk reads. Mutations made by
36 + ``check_for_updates`` to the loaded copy are captured via
37 + ``SettingsStore.save`` (also mocked) and re-applied to the seed so that
38 + subsequent ``load()`` calls reflect them — matching the persistence
39 + behavior end-to-end.
40 + """
41 +
42 + def _build(**overrides):
43 + kwargs = {
44 + "update_url": "http://test.url",
45 + "last_check": None,
46 + **overrides,
47 + }
48 + # Wrap the live object in a list so the inner closures can rebind it
49 + # while still letting tests read the latest state via ``current[0]``.
50 + current = [Settings(**kwargs)]
51 +
52 + def _load():
53 + return current[0].model_copy()
54 +
55 + def _save(updated):
56 + current[0] = updated.model_copy()
57 +
58 + mocker.patch("colab_cli.state.SettingsStore.load", side_effect=_load)
59 + mocker.patch("colab_cli.state.SettingsStore.save", side_effect=_save)
60 +
61 + # Expose a `.current` accessor returning the latest persisted state.
62 + # The returned proxy supports attribute access for convenience.
63 + class _Proxy:
64 + def __getattr__(self, name):
65 + return getattr(current[0], name)
66 +
67 + return _Proxy()
68 +
69 + return _build
70 +
71 +
72 +@pytest.fixture
73 +def mock_pypi(mocker):
74 + """Stub ``urllib.request.urlopen`` to return ``payload`` (or raise ``error``)."""
75 +
76 + def _mock(payload=None, *, error=None):
77 + if error is not None:
78 + mocker.patch("urllib.request.urlopen", side_effect=error)
79 + return
80 + m = mocker.patch("urllib.request.urlopen")
81 + m.return_value.__enter__.return_value.read.return_value = json.dumps(
82 + payload
83 + ).encode("utf-8")
84 +
85 + return _mock
86 +
87 +
88 +@pytest.fixture
89 +def app_version(mocker):
90 + """Pin the locally-installed CLI version to the requested value."""
91 +
92 + def _set(v):
93 + mocker.patch("colab_cli.auto_update.get_app_version", return_value=v)
94 +
95 + return _set
96 +
97 +
98 +# ---------- PyPI source --------------------------------------------------
99 +
100 +
101 +@pytest.mark.parametrize(
102 + "current, pypi_version, expected_message",
103 + [
104 + # Same version: up-to-date with latest = current.
105 + ("1.0.0", "1.0.0", "up to date (version: 1.0.0, latest: 1.0.0)"),
106 + # PyPI is older than installed: still up-to-date, latest reflects PyPI.
107 + ("1.1.0", "1.0.0", "up to date (version: 1.1.0, latest: 1.0.0)"),
108 + ],
109 +)
110 +def test_pypi_no_upgrade(
111 + app_version, fake_settings, mock_pypi, current, pypi_version, expected_message
112 +):
113 + app_version(current)
114 + mock_pypi({"info": {"version": pypi_version}})
115 + fake_settings()
116 +
117 + result = runner.invoke(app, ["update"])
118 + assert result.exit_code == 0
119 + assert expected_message in result.output
120 +
121 +
122 +def test_pypi_upgrade_uses_pip_hint(app_version, fake_settings, mock_pypi):
123 + app_version("1.0.0")
124 + mock_pypi({"info": {"version": "1.1.0"}})
125 + fake_settings()
126 +
127 + result = runner.invoke(app, ["update"])
128 + assert result.exit_code == 0
129 + assert "available: 1.1.0 (current: 1.0.0)" in result.output
130 + assert "Run 'pip install --upgrade colab' to update." in result.output
131 +
132 +
133 +def test_explicit_update_omits_disable_hint(app_version, fake_settings, mock_pypi):
134 + """`colab update` is explicit user opt-in; the 'how to silence' line
135 + should NOT appear (it would be condescending after the user just asked)."""
136 + app_version("1.0.0")
137 + mock_pypi({"info": {"version": "1.1.0"}})
138 + fake_settings()
139 +
140 + result = runner.invoke(app, ["update"])
141 + assert result.exit_code == 0
142 + assert "available: 1.1.0" in result.output
143 + assert "To silence this check" not in result.output
144 + assert "enable_update_check" not in result.output
145 +
146 +
147 +def test_background_check_includes_disable_hint(app_version, fake_settings, mock_pypi):
148 + """The daily background fetch (triggered by any non-quiet command)
149 + DOES include the 'how to silence' line so users have an obvious opt-out."""
150 + app_version("1.0.0")
151 + mock_pypi({"info": {"version": "1.1.0"}})
152 + fake_settings(last_check=datetime.now(timezone.utc) - timedelta(days=2))
153 +
154 + result = runner.invoke(app, ["sessions"])
155 + assert result.exit_code == 0
156 + assert "available: 1.1.0" in result.output
157 + assert "To silence this check" in result.output
158 + assert '"enable_update_check": false' in result.output
159 +
160 +
161 +def test_cached_banner_includes_disable_hint(mocker, app_version, fake_settings):
162 + """The cached banner shown between fetches is unsolicited; include the hint."""
163 + app_version("1.0.0")
164 + fake_settings(
165 + last_check=datetime.now(timezone.utc) - timedelta(hours=1),
166 + latest_version="1.2.0",
167 + )
168 + mocker.patch("colab_cli.auto_update.check_for_updates")
169 +
170 + result = runner.invoke(app, ["sessions"])
171 + assert result.exit_code == 0
172 + assert "available: 1.2.0" in result.output
173 + assert "To silence this check" in result.output
174 +
175 +
176 +# ---------- Resilience --------------------------------------------------
177 +
178 +
179 +def test_pypi_fetch_failure_omits_latest(app_version, fake_settings, mock_pypi):
180 + app_version("1.0.0")
181 + mock_pypi(error=OSError("network down"))
182 + fake_settings()
183 +
184 + result = runner.invoke(app, ["update"])
185 + assert result.exit_code == 0
186 + assert "Colab CLI is up to date (version: 1.0.0)" in result.output
187 + assert "latest:" not in result.output
188 +
189 +
190 +# ---------- Auto-update wiring ------------------------------------------
191 +
192 +
193 +def test_auto_update_runs_when_stale(app_version, fake_settings, mock_pypi):
194 + app_version("1.0.0")
195 + mock_pypi({"info": {"version": "1.1.0"}})
196 + fake_settings(last_check=datetime.now(timezone.utc) - timedelta(days=2))
197 +
198 + result = runner.invoke(app, ["sessions"])
199 + assert result.exit_code == 0
200 + assert "available: 1.1.0 (current: 1.0.0)" in result.output
201 +
202 +
203 +def test_auto_update_skips_when_recent(mocker, fake_settings):
204 + fake_settings(last_check=datetime.now(timezone.utc) - timedelta(hours=1))
205 + mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
206 +
207 + runner.invoke(app, ["sessions"])
208 + assert mock_check.call_count == 0
209 +
210 +
211 +def test_auto_update_runs_on_first_invocation(mocker, app_version, fake_settings):
212 + app_version("1.0.0")
213 + fake_settings() # last_check=None
214 + mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
215 +
216 + result = runner.invoke(app, ["sessions"])
217 + assert result.exit_code == 0
218 + assert mock_check.call_count == 1
219 +
220 +
221 +# ---------- `latest_version` cache --------------------------------------
222 +
223 +
224 +def test_settings_default_latest_version_is_none():
225 + assert Settings().latest_version is None
226 +
227 +
228 +def test_check_persists_latest_version_from_pypi(app_version, fake_settings, mock_pypi):
229 + app_version("1.0.0")
230 + mock_pypi({"info": {"version": "1.1.0"}})
231 + settings = fake_settings()
232 +
233 + result = runner.invoke(app, ["update"])
234 + assert result.exit_code == 0
235 + assert settings.latest_version == "1.1.0"
236 +
237 +
238 +def test_check_preserves_latest_version_on_fetch_failure(
239 + app_version, fake_settings, mock_pypi
240 +):
241 + """If the PyPI fetch fails, the cached `latest_version` must NOT be cleared."""
242 + app_version("1.0.0")
243 + mock_pypi(error=OSError("network down"))
244 + settings = fake_settings(latest_version="1.7.0")
245 +
246 + result = runner.invoke(app, ["update"])
247 + assert result.exit_code == 0
248 + assert settings.latest_version == "1.7.0"
249 +
250 +
251 +def test_check_does_not_downgrade_latest_version(app_version, fake_settings, mock_pypi):
252 + """A subsequent fetch returning an older version must not overwrite the cache."""
253 + app_version("1.0.0")
254 + mock_pypi({"info": {"version": "1.1.0"}})
255 + settings = fake_settings(latest_version="2.0.0")
256 +
257 + result = runner.invoke(app, ["update"])
258 + assert result.exit_code == 0
259 + assert settings.latest_version == "2.0.0"
260 +
261 +
262 +# ---------- Cached banner on every invocation ---------------------------
263 +
264 +
265 +def test_cached_banner_shown_when_throttled(mocker, app_version, fake_settings):
266 + """When the daily fetch is skipped, a cached newer `latest_version` still
267 + triggers the upgrade banner — without re-fetching."""
268 + app_version("1.0.0")
269 + fake_settings(
270 + last_check=datetime.now(timezone.utc) - timedelta(hours=1),
271 + latest_version="1.2.0",
272 + )
273 + mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
274 +
275 + result = runner.invoke(app, ["sessions"])
276 + assert result.exit_code == 0
277 + assert mock_check.call_count == 0 # throttle still active
278 + assert "available: 1.2.0 (current: 1.0.0)" in result.output
279 +
280 +
281 +def test_cached_banner_suppressed_when_up_to_date(mocker, app_version, fake_settings):
282 + """If the cached `latest_version` is not newer than the current install,
283 + no banner should appear."""
284 + app_version("1.2.0")
285 + fake_settings(
286 + last_check=datetime.now(timezone.utc) - timedelta(hours=1),
287 + latest_version="1.2.0",
288 + )
289 + mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
290 +
291 + result = runner.invoke(app, ["sessions"])
292 + assert result.exit_code == 0
293 + assert mock_check.call_count == 0
294 + assert "A new version" not in result.output
295 +
296 +
297 +def test_cached_banner_skipped_for_update_subcommand(
298 + mocker, app_version, fake_settings
299 +):
300 + """`colab update` does its own fetch + announce; the callback must not
301 + duplicate the banner from the cache."""
302 + app_version("1.0.0")
303 + fake_settings(
304 + last_check=datetime.now(timezone.utc) - timedelta(hours=1),
305 + latest_version="1.2.0",
306 + )
307 + # Stub check_for_updates so we can assert the callback didn't print twice.
308 + mock_check = mocker.patch(
309 + "colab_cli.auto_update.check_for_updates", return_value=None
310 + )
311 +
312 + result = runner.invoke(app, ["update"])
313 + assert result.exit_code == 0
314 + # The check ran (forced by `update`) but the cached banner from the
315 + # callback must NOT have fired.
316 + assert mock_check.call_count == 1
317 + assert result.output.count("A new version") == 0
318 +
319 +
320 +def test_cached_banner_suppressed_when_update_check_disabled(
321 + mocker, app_version, fake_settings
322 +):
323 + """`enable_update_check=False` is a global opt-out: no fetch AND no cached
324 + banner. The user has explicitly disabled the update-check subsystem."""
325 + app_version("1.0.0")
326 + fake_settings(
327 + enable_update_check=False,
328 + latest_version="1.2.0",
329 + )
330 + mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
331 +
332 + result = runner.invoke(app, ["sessions"])
333 + assert result.exit_code == 0
334 + assert mock_check.call_count == 0
335 + assert "A new version" not in result.output
336 +
337 +
338 +# ---------- Quiet subcommands skip the auto-update banner ---------------
339 +
340 +
341 +@pytest.mark.parametrize("subcommand", ["version", "log", "pay", "help"])
342 +def test_background_check_skipped_for_quiet_subcommands(
343 + mocker, app_version, fake_settings, subcommand
344 +):
345 + """`version`, `log`, `pay`, and `help` are short-lived informational
346 + commands. Their output should never be polluted by the upgrade banner —
347 + no daily fetch and no cached banner should fire from the global
348 + callback. (`colab update` is exempted separately because it runs its
349 + own check.)"""
350 + app_version("1.0.0")
351 + fake_settings(
352 + # Force the daily fetch to be DUE: if the callback runs at all, it
353 + # would call check_for_updates() and we'd see the assertion fail.
354 + last_check=datetime.now(timezone.utc) - timedelta(days=2),
355 + # Also seed a cached newer version so we'd see the cached banner if
356 + # the callback fell through to maybe_show_cached_banner instead.
357 + latest_version="1.2.0",
358 + )
359 + # Patch `webbrowser.open` to keep `colab pay` from launching a browser
360 + # in the test environment.
361 + mocker.patch("webbrowser.open")
362 + mock_check = mocker.patch("colab_cli.auto_update.check_for_updates")
363 +
364 + result = runner.invoke(app, [subcommand])
365 + assert result.exit_code == 0, result.output
366 + assert mock_check.call_count == 0, (
367 + f"`colab {subcommand}` should NOT trigger the daily update fetch."
368 + )
369 + assert "A new version" not in result.output, (
370 + f"`colab {subcommand}` should NOT print the cached upgrade banner."
371 + )
372 +
373 +
374 +# ---------- `--install` self-install flag -------------------------------
375 +
376 +
377 +def test_install_flag_default_does_not_install(
378 + mocker, app_version, fake_settings, mock_pypi
379 +):
380 + """Without `--install`, no install command is invoked even when a newer
381 + version is available on PyPI."""
382 + app_version("1.0.0")
383 + mock_pypi({"info": {"version": "1.1.0"}})
384 + fake_settings()
385 + run = mocker.patch("colab_cli.auto_update.subprocess.run")
386 +
387 + result = runner.invoke(app, ["update"])
388 + assert result.exit_code == 0
389 + assert run.call_count == 0
390 +
391 +
392 +def test_install_flag_runs_pip_install_upgrade(
393 + mocker, app_version, fake_settings, mock_pypi
394 +):
395 + """`colab update --install` shells out to `pip install -U google-colab-cli`
396 + when PyPI reports a newer version."""
397 + app_version("1.0.0")
398 + mock_pypi({"info": {"version": "1.1.0"}})
399 + fake_settings()
400 + mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
401 + run = mocker.patch(
402 + "colab_cli.auto_update.subprocess.run",
403 + return_value=mocker.Mock(returncode=0),
404 + )
405 +
406 + result = runner.invoke(app, ["update", "--install"])
407 + assert result.exit_code == 0
408 + assert run.call_count == 1
409 + args, _ = run.call_args
410 + # Use sys.executable to avoid PATH ambiguity / virtualenv mixups.
411 + cmd = args[0]
412 + assert cmd[1:] == ["-m", "pip", "install", "-U", "google-colab-cli"]
413 +
414 +
415 +def test_install_flag_errors_on_non_linux(
416 + mocker, app_version, fake_settings, mock_pypi
417 +):
418 + """`--install` is gated to Linux; on other platforms the command must
419 + exit non-zero with an explanatory message and skip the pip subprocess."""
420 + app_version("1.0.0")
421 + mock_pypi({"info": {"version": "1.1.0"}})
422 + fake_settings()
423 + mocker.patch("colab_cli.commands.utility.platform.system", return_value="Darwin")
424 + run = mocker.patch("colab_cli.auto_update.subprocess.run")
425 +
426 + result = runner.invoke(app, ["update", "--install"])
427 + assert result.exit_code != 0
428 + assert run.call_count == 0
429 + assert "only supported on Linux" in result.output
430 +
431 +
432 +def test_install_flag_no_op_when_already_up_to_date(
433 + mocker, app_version, fake_settings, mock_pypi
434 +):
435 + """`--install` should not invoke pip when the cached `latest_version`
436 + is not newer than the current install."""
437 + app_version("1.1.0")
438 + mock_pypi({"info": {"version": "1.1.0"}})
439 + fake_settings()
440 + mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
441 + run = mocker.patch("colab_cli.auto_update.subprocess.run")
442 +
443 + result = runner.invoke(app, ["update", "--install"])
444 + assert result.exit_code == 0
445 + assert run.call_count == 0
446 +
447 +
448 +def test_install_flag_propagates_pip_failure(
449 + mocker, app_version, fake_settings, mock_pypi
450 +):
451 + """If `pip install -U` exits non-zero, `colab update --install` must too."""
452 + app_version("1.0.0")
453 + mock_pypi({"info": {"version": "1.1.0"}})
454 + fake_settings()
455 + mocker.patch("colab_cli.commands.utility.platform.system", return_value="Linux")
456 + mocker.patch(
457 + "colab_cli.auto_update.subprocess.run",
458 + return_value=mocker.Mock(returncode=2),
459 + )
460 +
461 + result = runner.invoke(app, ["update", "--install"])
462 + assert result.exit_code == 2
tests/test_url.py new
+229
@@ -0,0 +1,229 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Tests for `colab url`: print a browser URL that connects the Colab
16 +frontend to an existing colab-cli session.
17 +
18 +URL format:
19 +
20 + https://<host>/notebooks/empty.ipynb?dbu=<urlencoded-/tun/m/endpoint>
21 +
22 +The `dbu` query parameter is the Colab frontend's `datalab_backend_url`
23 +development flag; the frontend resolves the value against
24 +`window.location.origin` and attaches the kernel to the supplied
25 +`/tun/m/<endpoint>` path instead of allocating a fresh VM.
26 +
27 +Note: `dbu` is a development flag, so URL-overriding it may be gated by
28 +the Colab frontend; some users may need to use the hash-based
29 +`#datalabBackendUrl=...` form instead (we don't support that today; file
30 +an issue if you need it).
31 +"""
32 +
33 +from unittest.mock import MagicMock, patch
34 +from urllib.parse import parse_qs, quote, urlparse
35 +
36 +from typer.testing import CliRunner
37 +
38 +from colab_cli.cli import app
39 +
40 +runner = CliRunner()
41 +
42 +
43 +def _make_session(name: str = "s1", endpoint: str = "abc123def"):
44 + s = MagicMock()
45 + s.name = name
46 + s.endpoint = endpoint
47 + return s
48 +
49 +
50 +def _parse_url_output(output: str) -> str:
51 + """Pull the single URL line out of `colab url` output."""
52 + candidates = [line.strip() for line in output.splitlines() if "dbu=" in line]
53 + assert len(candidates) == 1, (
54 + f"Expected exactly one URL line containing 'dbu=', got {candidates!r}"
55 + )
56 + return candidates[0]
57 +
58 +
59 +def test_url_explicit_session(mock_common_state):
60 + """`colab url -s NAME` prints the connect URL for that session.
61 +
62 + Format: ``https://<host>/notebooks/empty.ipynb?dbu=<urlencoded path>``.
63 + The path must land on `empty.ipynb` so the user sees a usable notebook
64 + UI; the `dbu` query param tells the frontend to skip /tun/m/assign and
65 + attach to our existing endpoint.
66 + """
67 + s = _make_session(name="my-sess", endpoint="ep-XYZ")
68 + mock_common_state.store.get.return_value = s
69 + mock_common_state.resolve_session.return_value = "my-sess"
70 +
71 + result = runner.invoke(app, ["url", "-s", "my-sess"])
72 +
73 + assert result.exit_code == 0, result.output
74 + url = _parse_url_output(result.output)
75 +
76 + parsed = urlparse(url)
77 + assert parsed.scheme == "https"
78 + assert parsed.netloc == "colab.research.google.com"
79 + assert parsed.path == "/notebooks/empty.ipynb"
80 +
81 + # `dbu` must be the URL-encoded path `/tun/m/<endpoint>`. We assert on
82 + # the decoded form rather than the raw encoding to keep the test robust
83 + # to which characters the encoder happens to escape (e.g. `/` may or
84 + # may not be escaped depending on `safe=`); what matters is round-trip
85 + # decoding produces the right backend path.
86 + qs = parse_qs(parsed.query)
87 + assert qs.get("dbu") == ["/tun/m/ep-XYZ"]
88 +
89 + # And we DO actually URL-encode the slashes so the value survives any
90 + # downstream re-parsing that treats the query string non-strictly.
91 + assert "dbu=%2Ftun%2Fm%2Fep-XYZ" in url
92 +
93 +
94 +def test_url_resolves_unique_session(mock_common_state):
95 + """`colab url` (no -s) uses the unique-session resolution path."""
96 + s = _make_session(name="only-sess", endpoint="solo-EP")
97 + mock_common_state.store.get.return_value = s
98 + mock_common_state.resolve_session.return_value = "only-sess"
99 +
100 + result = runner.invoke(app, ["url"])
101 +
102 + assert result.exit_code == 0, result.output
103 + url = _parse_url_output(result.output)
104 + assert "%2Ftun%2Fm%2Fsolo-EP" in url
105 + # Resolution went through the shared helper, not by hardcoding the name.
106 + mock_common_state.resolve_session.assert_called_once_with(None)
107 +
108 +
109 +def test_url_session_not_found(mock_common_state):
110 + """If the resolved session has no local state, exit non-zero with a clear
111 + message rather than printing a malformed URL."""
112 + mock_common_state.resolve_session.return_value = "ghost"
113 + mock_common_state.store.get.return_value = None
114 +
115 + result = runner.invoke(app, ["url", "-s", "ghost"])
116 +
117 + assert result.exit_code != 0
118 + assert "ghost" in result.output
119 + assert "not found" in result.output.lower()
120 +
121 +
122 +def test_url_custom_host(mock_common_state):
123 + """`--host` overrides the default frontend host. `dbu` is a path-only
124 + value (resolved against `window.location.origin` in the frontend), so
125 + the host swap only affects the page origin, not the embedded backend
126 + path."""
127 + s = _make_session(endpoint="ep1")
128 + mock_common_state.store.get.return_value = s
129 + mock_common_state.resolve_session.return_value = "s1"
130 +
131 + result = runner.invoke(
132 + app, ["url", "-s", "s1", "--host", "https://colab.sandbox.google.com"]
133 + )
134 +
135 + assert result.exit_code == 0, result.output
136 + url = _parse_url_output(result.output)
137 + parsed = urlparse(url)
138 + assert parsed.netloc == "colab.sandbox.google.com"
139 + assert parsed.path == "/notebooks/empty.ipynb"
140 + assert parse_qs(parsed.query).get("dbu") == ["/tun/m/ep1"]
141 +
142 +
143 +def test_url_host_normalises_trailing_slash(mock_common_state):
144 + """`--host https://example.com/` (with trailing slash) must not produce
145 + a double slash before `/notebooks/empty.ipynb`."""
146 + s = _make_session(endpoint="ep2")
147 + mock_common_state.store.get.return_value = s
148 + mock_common_state.resolve_session.return_value = "s1"
149 +
150 + result = runner.invoke(
151 + app, ["url", "-s", "s1", "--host", "https://colab.research.google.com/"]
152 + )
153 +
154 + assert result.exit_code == 0
155 + assert "https://colab.research.google.com//notebooks/" not in result.output
156 + assert "https://colab.research.google.com/notebooks/empty.ipynb" in result.output
157 +
158 +
159 +def test_url_endpoint_with_special_chars_is_encoded(mock_common_state):
160 + """Endpoints are opaque server-issued IDs but we should not assume their
161 + character set. Anything outside the unreserved URL set must be escaped
162 + so the frontend's `new URL(...)` parser sees the intended path."""
163 + # Intentionally include characters that MUST be escaped if they ever
164 + # appeared in an endpoint (e.g. `&`, `?`, `#`, space, `=`).
165 + s = _make_session(endpoint="weird ep&?=#")
166 + mock_common_state.store.get.return_value = s
167 + mock_common_state.resolve_session.return_value = "s1"
168 +
169 + result = runner.invoke(app, ["url", "-s", "s1"])
170 + assert result.exit_code == 0, result.output
171 + url = _parse_url_output(result.output)
172 + parsed = urlparse(url)
173 +
174 + # The encoded endpoint must round-trip via the standard query parser.
175 + assert parse_qs(parsed.query).get("dbu") == ["/tun/m/weird ep&?=#"]
176 + # And the raw URL must contain the percent-encoded form (not the literal).
177 + assert quote("weird ep&?=#", safe="") in url
178 +
179 +
180 +def test_url_open_flag_launches_browser(mock_common_state):
181 + """`--open` calls webbrowser.open() with the same URL it printed."""
182 + s = _make_session(endpoint="ep-OPEN")
183 + mock_common_state.store.get.return_value = s
184 + mock_common_state.resolve_session.return_value = "s1"
185 +
186 + with patch("webbrowser.open") as mock_open:
187 + result = runner.invoke(app, ["url", "-s", "s1", "--open"])
188 +
189 + assert result.exit_code == 0, result.output
190 + mock_open.assert_called_once()
191 + opened_url = mock_open.call_args[0][0]
192 + assert "dbu=" in opened_url
193 + assert "%2Ftun%2Fm%2Fep-OPEN" in opened_url
194 + # And the URL was also printed (so users see what was opened, and
195 + # piping still works).
196 + assert opened_url in result.output
197 +
198 +
199 +def test_url_no_open_by_default(mock_common_state):
200 + """Default behaviour: print only, do NOT auto-open the browser. This keeps
201 + the command pipeable (`colab url | xclip`, `colab url | pbcopy`, etc.)."""
202 + s = _make_session(endpoint="ep3")
203 + mock_common_state.store.get.return_value = s
204 + mock_common_state.resolve_session.return_value = "s1"
205 +
206 + with patch("webbrowser.open") as mock_open:
207 + result = runner.invoke(app, ["url", "-s", "s1"])
208 +
209 + assert result.exit_code == 0
210 + mock_open.assert_not_called()
211 +
212 +
213 +def test_url_output_is_pipeable(mock_common_state):
214 + """The printed URL line must be machine-parseable: a single line with no
215 + leading `[colab]` chatter, so `colab url -s s1 | xclip` works.
216 + """
217 + s = _make_session(endpoint="ep-PIPE")
218 + mock_common_state.store.get.return_value = s
219 + mock_common_state.resolve_session.return_value = "s1"
220 +
221 + result = runner.invoke(app, ["url", "-s", "s1"])
222 +
223 + assert result.exit_code == 0
224 + url_lines = [line for line in result.output.splitlines() if "dbu=" in line]
225 + assert len(url_lines) == 1, f"Expected exactly one URL line, got: {url_lines}"
226 + assert not url_lines[0].lstrip().startswith("[colab]"), (
227 + f"URL line should not be prefixed with '[colab]' so it's pipeable: "
228 + f"{url_lines[0]!r}"
229 + )
tests/test_utils.py new
+58
@@ -0,0 +1,58 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +import base64
16 +from unittest.mock import MagicMock, patch
17 +
18 +from colab_cli.utils import handle_image, print_kitty
19 +
20 +
21 +@patch("colab_cli.utils.sys.stdout.isatty", return_value=True)
22 +def test_print_kitty_emits_escape_sequence_on_tty(_mock_isatty, capsys):
23 + print_kitty(b"fake-png-bytes")
24 + captured = capsys.readouterr()
25 + assert "_Ga=T,f=100;" in captured.out
26 + b64 = base64.b64encode(b"fake-png-bytes").decode("ascii")
27 + assert b64 in captured.out
28 +
29 +
30 +@patch("colab_cli.utils.sys.stdout.isatty", return_value=False)
31 +def test_print_kitty_silent_when_stdout_not_tty(_mock_isatty, capsys):
32 + """Kitty graphics escape sequences are useless and visually corrupt the
33 + output when stdout is redirected (e.g. `colab exec ... > log.txt`,
34 + `colab exec ... | grep ...`, or any non-Kitty terminal). When stdout is
35 + not a TTY, print_kitty must not emit anything.
36 + """
37 + print_kitty(b"fake-png-bytes")
38 + captured = capsys.readouterr()
39 + assert captured.out == "", (
40 + f"Expected no output when stdout is not a TTY, got: {captured.out!r}"
41 + )
42 +
43 +
44 +@patch("colab_cli.utils.tempfile.NamedTemporaryFile")
45 +@patch("colab_cli.utils.print_kitty")
46 +def test_handle_image(mock_print_kitty, mock_tempfile, capsys):
47 + mock_tmp = MagicMock()
48 + mock_tmp.name = "/tmp/fake.png"
49 + mock_tempfile.return_value = mock_tmp
50 +
51 + handle_image(base64.b64encode(b"test").decode("ascii"), "image/png")
52 +
53 + mock_print_kitty.assert_called_once_with(b"test")
54 + mock_tmp.write.assert_called_once_with(b"test")
55 + mock_tmp.close.assert_called_once()
56 +
57 + captured = capsys.readouterr()
58 + assert "/tmp/fake.png" in captured.out
tests/test_version.py new
+51
@@ -0,0 +1,51 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +from importlib.metadata import PackageNotFoundError
16 +from typer.testing import CliRunner
17 +from unittest.mock import patch
18 +
19 +from colab_cli.cli import app
20 +
21 +runner = CliRunner()
22 +
23 +
24 +def test_version_installed():
25 + with patch("colab_cli.auto_update.installed_version") as mock_version:
26 + mock_version.return_value = "0.2.0"
27 + result = runner.invoke(app, ["version"])
28 + assert result.exit_code == 0
29 + assert "Version: 0.2.0" in result.output
30 +
31 +
32 +def test_version_git_fallback():
33 + with patch("colab_cli.auto_update.installed_version") as mock_version:
34 + mock_version.side_effect = PackageNotFoundError
35 +
36 + with patch("subprocess.check_output") as mock_git:
37 + mock_git.return_value = "abc1234"
38 + result = runner.invoke(app, ["version"])
39 + assert result.exit_code == 0
40 + assert "Version: abc1234" in result.output
41 +
42 +
43 +def test_version_unknown():
44 + with patch("colab_cli.auto_update.installed_version") as mock_version:
45 + mock_version.side_effect = PackageNotFoundError
46 +
47 + with patch("subprocess.check_output") as mock_git:
48 + mock_git.side_effect = Exception("git not found")
49 + result = runner.invoke(app, ["version"])
50 + assert result.exit_code == 0
51 + assert "Version: unknown" in result.output
tests/test_whoami.py new
+172
@@ -0,0 +1,172 @@
1 +# Copyright 2026 Google LLC
2 +#
3 +# Licensed under the Apache License, Version 2.0 (the "License");
4 +# you may not use this file except in compliance with the License.
5 +# You may obtain a copy of the License at
6 +#
7 +# http://www.apache.org/licenses/LICENSE-2.0
8 +#
9 +# Unless required by applicable law or agreed to in writing, software
10 +# distributed under the License is distributed on an "AS IS" BASIS,
11 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 +# See the License for the specific language governing permissions and
13 +# limitations under the License.
14 +
15 +"""Tests for the developer-only `colab whoami` command.
16 +
17 +This is a debugging aid that resolves the active credentials, mints an access
18 +token, and queries Google's tokeninfo endpoint to print the human-readable
19 +identity (email), grant scopes, and expiry of whatever the CLI is about to
20 +send to colab.research.google.com / colab.pa.googleapis.com.
21 +"""
22 +
23 +import sys
24 +from unittest.mock import MagicMock, patch
25 +
26 +import pytest
27 +
28 +from colab_cli.cli import main
29 +from colab_cli.auth import AuthProvider
30 +
31 +
32 +# Unambiguously-fake placeholders so credential-scanner pre-commit hooks
33 +# don't false-positive on `ya29.*` strings. The whoami code only ever uses
34 +# these as opaque payloads passed to a mocked urllib.request.urlopen.
35 +_FAKE_TOKEN = "TEST-TOKEN-PLACEHOLDER"
36 +
37 +
38 +def _fake_creds(token: str = _FAKE_TOKEN):
39 + """Build a credentials-like mock: has .token and .refresh()."""
40 + creds = MagicMock()
41 + creds.token = token
42 + creds.refresh = MagicMock()
43 + return creds
44 +
45 +
46 +def _fake_authed_session(token: str = _FAKE_TOKEN):
47 + """Mimic google.auth.transport.requests.AuthorizedSession enough for whoami."""
48 + sess = MagicMock()
49 + sess.credentials = _fake_creds(token)
50 + return sess
51 +
52 +
53 +def test_whoami_prints_human_readable_summary(mock_common_state, capsys):
54 + """Default invocation should fetch the token, hit tokeninfo, and print
55 + a labelled summary including email, the active auth provider, scopes
56 + (one per line), and an Expires line."""
57 + mock_common_state.auth_provider = AuthProvider.ADC
58 +
59 + fake_response = MagicMock()
60 + fake_response.status_code = 200
61 + fake_response.json.return_value = {
62 + "email": "user@example.com",
63 + "scope": (
64 + "https://www.googleapis.com/auth/userinfo.email "
65 + "https://www.googleapis.com/auth/colaboratory"
66 + ),
67 + "expires_in": "2847",
68 + "audience": "32555940559.apps.googleusercontent.com",
69 + }
70 +
71 + with patch("colab_cli.auth.get_credentials", return_value=_fake_authed_session()):
72 + with patch("urllib.request.urlopen") as mock_urlopen:
73 + cm = MagicMock()
74 + cm.__enter__ = MagicMock(return_value=cm)
75 + cm.__exit__ = MagicMock(return_value=False)
76 + cm.read.return_value = b'{"email":"user@example.com","scope":"https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/colaboratory","expires_in":"2847","audience":"32555940559.apps.googleusercontent.com"}'
77 + cm.status = 200
78 + mock_urlopen.return_value = cm
79 +
80 + with patch.object(sys, "argv", ["colab", "--auth=adc", "whoami"]):
81 + with pytest.raises(SystemExit) as error:
82 + main()
83 + assert error.value.code == 0
84 +
85 + out = capsys.readouterr().out
86 + assert "user@example.com" in out
87 + assert "adc" in out.lower()
88 + assert "userinfo.email" in out
89 + assert "colaboratory" in out
90 + # Expiry should be rendered in a human form (minutes), not raw seconds.
91 + assert "47m" in out or "47 min" in out
92 +
93 +
94 +def test_whoami_handles_tokeninfo_error_gracefully(mock_common_state, capsys):
95 + """If tokeninfo returns 4xx (e.g. expired/revoked token), whoami should
96 + print an error and exit non-zero rather than blowing up with a stack trace.
97 + Developers reading the message should be able to tell what happened.
98 + """
99 + mock_common_state.auth_provider = AuthProvider.ADC
100 +
101 + with patch("colab_cli.auth.get_credentials", return_value=_fake_authed_session()):
102 + with patch("urllib.request.urlopen") as mock_urlopen:
103 + import urllib.error
104 +
105 + mock_urlopen.side_effect = urllib.error.HTTPError(
106 + url="https://oauth2.googleapis.com/tokeninfo",
107 + code=400,
108 + msg="Bad Request",
109 + hdrs=None,
110 + fp=None,
111 + )
112 +
113 + with patch.object(sys, "argv", ["colab", "--auth=adc", "whoami"]):
114 + with pytest.raises(SystemExit) as error:
115 + main()
116 + assert error.value.code != 0
117 +
118 + captured = capsys.readouterr()
119 + assert "tokeninfo" in (captured.out + captured.err).lower() or "400" in (
120 + captured.out + captured.err
121 + )
122 +
123 +
124 +def test_whoami_is_hidden_from_top_level_help(mock_common_state, capsys):
125 + """`colab --help` should not list `whoami` (it's a developer tool).
126 + Also asserts that `colab whoami --help` still works (the command is
127 + hidden, not removed)."""
128 + # `--help` exits 0
129 + with patch.object(sys, "argv", ["colab", "--help"]):
130 + with pytest.raises(SystemExit) as error:
131 + main()
132 + assert error.value.code == 0
133 + out = capsys.readouterr().out
134 + assert "whoami" not in out, (
135 + f"`whoami` should be hidden from `colab --help`, but appeared in:\n{out}"
136 + )
137 +
138 + # Confirm `colab whoami --help` is still reachable.
139 + with patch.object(sys, "argv", ["colab", "whoami", "--help"]):
140 + with pytest.raises(SystemExit) as error:
141 + main()
142 + assert error.value.code == 0
143 + out2 = capsys.readouterr().out
144 + assert "whoami" in out2.lower(), (
145 + f"`colab whoami --help` should describe the command, got:\n{out2}"
146 + )
147 +
148 +
149 +def test_whoami_refreshes_credentials_before_reading_token(mock_common_state):
150 + """Some credentials (ADC service account, GCE) lazy-mint the token only
151 + when refresh() is called. whoami must call refresh() before reading
152 + creds.token, otherwise creds.token may be None even for valid creds.
153 + """
154 + mock_common_state.auth_provider = AuthProvider.ADC
155 + sess = _fake_authed_session(token="TEST-TOKEN-AFTER-REFRESH")
156 +
157 + with patch("colab_cli.auth.get_credentials", return_value=sess):
158 + with patch("urllib.request.urlopen") as mock_urlopen:
159 + cm = MagicMock()
160 + cm.__enter__ = MagicMock(return_value=cm)
161 + cm.__exit__ = MagicMock(return_value=False)
162 + cm.read.return_value = (
163 + b'{"email":"x@y.com","scope":"a b","expires_in":"60"}'
164 + )
165 + cm.status = 200
166 + mock_urlopen.return_value = cm
167 +
168 + with patch.object(sys, "argv", ["colab", "--auth=adc", "whoami"]):
169 + with pytest.raises(SystemExit):
170 + main()
171 +
172 + sess.credentials.refresh.assert_called_once()
uv.lock new
+1025
@@ -0,0 +1,1025 @@
1 +version = 1
2 +revision = 3
3 +requires-python = ">=3.13"
4 +
5 +[[package]]
6 +name = "annotated-doc"
7 +version = "0.0.4"
8 +source = { registry = "https://pypi.org/simple" }
9 +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
10 +wheels = [
11 + { url = "https://files.pythonhosted.org/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" },
12 +]
13 +
14 +[[package]]
15 +name = "annotated-types"
16 +version = "0.7.0"
17 +source = { registry = "https://pypi.org/simple" }
18 +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
19 +wheels = [
20 + { url = "https://files.pythonhosted.org/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" },
21 +]
22 +
23 +[[package]]
24 +name = "attrs"
25 +version = "26.1.0"
26 +source = { registry = "https://pypi.org/simple" }
27 +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
28 +wheels = [
29 + { url = "https://files.pythonhosted.org/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" },
30 +]
31 +
32 +[[package]]
33 +name = "certifi"
34 +version = "2026.2.25"
35 +source = { registry = "https://pypi.org/simple" }
36 +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
37 +wheels = [
38 + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
39 +]
40 +
41 +[[package]]
42 +name = "cffi"
43 +version = "2.0.0"
44 +source = { registry = "https://pypi.org/simple" }
45 +dependencies = [
46 + { name = "pycparser", marker = "implementation_name != 'PyPy'" },
47 +]
48 +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
49 +wheels = [
50 + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
51 + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
52 + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
53 + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
54 + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
55 + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
56 + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
57 + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
58 + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
59 + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
60 + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
61 + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
62 + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
63 + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
64 + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
65 + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
66 + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
67 + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
68 + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
69 + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
70 + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
71 + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
72 + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
73 + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
74 + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
75 + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
76 + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
77 + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
78 + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
79 + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
80 + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
81 + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
82 + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
83 + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
84 +]
85 +
86 +[[package]]
87 +name = "charset-normalizer"
88 +version = "3.4.5"
89 +source = { registry = "https://pypi.org/simple" }
90 +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
91 +wheels = [
92 + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" },
93 + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" },
94 + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" },
95 + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" },
96 + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" },
97 + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" },
98 + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" },
99 + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" },
100 + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" },
101 + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" },
102 + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" },
103 + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" },
104 + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" },
105 + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" },
106 + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" },
107 + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" },
108 + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
109 + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
110 + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
111 + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
112 + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
113 + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
114 + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
115 + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
116 + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
117 + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
118 + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
119 + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
120 + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
121 + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
122 + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
123 + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
124 + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
125 +]
126 +
127 +[[package]]
128 +name = "click"
129 +version = "8.3.1"
130 +source = { registry = "https://pypi.org/simple" }
131 +dependencies = [
132 + { name = "colorama", marker = "sys_platform == 'win32'" },
133 +]
134 +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
135 +wheels = [
136 + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
137 +]
138 +
139 +[[package]]
140 +name = "colab"
141 +source = { editable = "." }
142 +dependencies = [
143 + { name = "google-auth" },
144 + { name = "google-auth-oauthlib" },
145 + { name = "jupyter-kernel-client" },
146 + { name = "nbformat" },
147 + { name = "packaging" },
148 + { name = "prompt-toolkit" },
149 + { name = "pydantic" },
150 + { name = "pygments" },
151 + { name = "pytest" },
152 + { name = "pytest-cov" },
153 + { name = "pytest-mock" },
154 + { name = "requests" },
155 + { name = "rich" },
156 + { name = "typer" },
157 +]
158 +
159 +[package.dev-dependencies]
160 +dev = [
161 + { name = "ruff" },
162 +]
163 +
164 +[package.metadata]
165 +requires-dist = [
166 + { name = "google-auth", specifier = ">=2.49.1" },
167 + { name = "google-auth-oauthlib", specifier = ">=1.3.0" },
168 + { name = "jupyter-kernel-client", git = "https://github.com/googlecolab/jupyter-kernel-client.git" },
169 + { name = "nbformat", specifier = ">=5.10.4" },
170 + { name = "packaging", specifier = ">=24.0" },
171 + { name = "prompt-toolkit", specifier = ">=3.0.52" },
172 + { name = "pydantic", specifier = ">=2.12.5" },
173 + { name = "pygments", specifier = ">=2.19.2" },
174 + { name = "pytest", specifier = ">=9.0.2" },
175 + { name = "pytest-cov", specifier = ">=7.0.0" },
176 + { name = "pytest-mock", specifier = ">=3.15.1" },
177 + { name = "requests", specifier = ">=2.32.5" },
178 + { name = "rich", specifier = ">=14.3.3" },
179 + { name = "typer", specifier = ">=0.24.1" },
180 +]
181 +
182 +[package.metadata.requires-dev]
183 +dev = [{ name = "ruff", specifier = ">=0.15.6" }]
184 +
185 +[[package]]
186 +name = "colorama"
187 +version = "0.4.6"
188 +source = { registry = "https://pypi.org/simple" }
189 +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
190 +wheels = [
191 + { url = "https://files.pythonhosted.org/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" },
192 +]
193 +
194 +[[package]]
195 +name = "coverage"
196 +version = "7.13.4"
197 +source = { registry = "https://pypi.org/simple" }
198 +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" }
199 +wheels = [
200 + { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" },
201 + { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" },
202 + { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" },
203 + { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" },
204 + { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" },
205 + { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" },
206 + { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" },
207 + { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" },
208 + { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" },
209 + { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" },
210 + { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" },
211 + { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" },
212 + { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" },
213 + { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" },
214 + { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" },
215 + { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" },
216 + { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" },
217 + { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" },
218 + { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" },
219 + { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" },
220 + { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" },
221 + { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" },
222 + { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" },
223 + { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" },
224 + { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" },
225 + { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" },
226 + { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" },
227 + { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" },
228 + { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" },
229 + { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" },
230 + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" },
231 + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" },
232 + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" },
233 + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" },
234 + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" },
235 + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" },
236 + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" },
237 + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" },
238 + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" },
239 + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" },
240 + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" },
241 + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" },
242 + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" },
243 + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" },
244 + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" },
245 + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" },
246 + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" },
247 + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" },
248 + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" },
249 + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" },
250 + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" },
251 + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" },
252 + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" },
253 + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" },
254 + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" },
255 + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" },
256 + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" },
257 + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" },
258 + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" },
259 + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" },
260 + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" },
261 +]
262 +
263 +[[package]]
264 +name = "cryptography"
265 +version = "46.0.5"
266 +source = { registry = "https://pypi.org/simple" }
267 +dependencies = [
268 + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
269 +]
270 +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" }
271 +wheels = [
272 + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" },
273 + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" },
274 + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" },
275 + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" },
276 + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" },
277 + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" },
278 + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" },
279 + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" },
280 + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" },
281 + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" },
282 + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" },
283 + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" },
284 + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" },
285 + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" },
286 + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" },
287 + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" },
288 + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" },
289 + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" },
290 + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" },
291 + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" },
292 + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" },
293 + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" },
294 + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" },
295 + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" },
296 + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" },
297 + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" },
298 + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" },
299 + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" },
300 + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" },
301 + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" },
302 + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" },
303 + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" },
304 + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" },
305 + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" },
306 + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" },
307 + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" },
308 + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" },
309 + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" },
310 + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" },
311 + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" },
312 + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" },
313 + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" },
314 +]
315 +
316 +[[package]]
317 +name = "fastjsonschema"
318 +version = "2.21.2"
319 +source = { registry = "https://pypi.org/simple" }
320 +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" }
321 +wheels = [
322 + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" },
323 +]
324 +
325 +[[package]]
326 +name = "google-auth"
327 +version = "2.49.1"
328 +source = { registry = "https://pypi.org/simple" }
329 +dependencies = [
330 + { name = "cryptography" },
331 + { name = "pyasn1-modules" },
332 +]
333 +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
334 +wheels = [
335 + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
336 +]
337 +
338 +[[package]]
339 +name = "google-auth-oauthlib"
340 +version = "1.3.0"
341 +source = { registry = "https://pypi.org/simple" }
342 +dependencies = [
343 + { name = "google-auth" },
344 + { name = "requests-oauthlib" },
345 +]
346 +sdist = { url = "https://files.pythonhosted.org/packages/ac/b4/1b19567e4c567b796f5c593d89895f3cfae5a38e04f27c6af87618fd0942/google_auth_oauthlib-1.3.0.tar.gz", hash = "sha256:cd39e807ac7229d6b8b9c1e297321d36fcc8a9e4857dff4301870985df51a528", size = 21777, upload-time = "2026-02-27T14:13:01.489Z" }
347 +wheels = [
348 + { url = "https://files.pythonhosted.org/packages/2f/56/909fd5632226d3fba31d7aeffd4754410735d49362f5809956fe3e9af344/google_auth_oauthlib-1.3.0-py3-none-any.whl", hash = "sha256:386b3fb85cf4a5b819c6ad23e3128d975216b4cac76324de1d90b128aaf38f29", size = 19308, upload-time = "2026-02-27T14:12:47.865Z" },
349 +]
350 +
351 +[[package]]
352 +name = "idna"
353 +version = "3.11"
354 +source = { registry = "https://pypi.org/simple" }
355 +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
356 +wheels = [
357 + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
358 +]
359 +
360 +[[package]]
361 +name = "iniconfig"
362 +version = "2.3.0"
363 +source = { registry = "https://pypi.org/simple" }
364 +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
365 +wheels = [
366 + { url = "https://files.pythonhosted.org/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" },
367 +]
368 +
369 +[[package]]
370 +name = "jsonschema"
371 +version = "4.26.0"
372 +source = { registry = "https://pypi.org/simple" }
373 +dependencies = [
374 + { name = "attrs" },
375 + { name = "jsonschema-specifications" },
376 + { name = "referencing" },
377 + { name = "rpds-py" },
378 +]
379 +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
380 +wheels = [
381 + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
382 +]
383 +
384 +[[package]]
385 +name = "jsonschema-specifications"
386 +version = "2025.9.1"
387 +source = { registry = "https://pypi.org/simple" }
388 +dependencies = [
389 + { name = "referencing" },
390 +]
391 +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
392 +wheels = [
393 + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
394 +]
395 +
396 +[[package]]
397 +name = "jupyter-client"
398 +version = "8.8.0"
399 +source = { registry = "https://pypi.org/simple" }
400 +dependencies = [
401 + { name = "jupyter-core" },
402 + { name = "python-dateutil" },
403 + { name = "pyzmq" },
404 + { name = "tornado" },
405 + { name = "traitlets" },
406 +]
407 +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" }
408 +wheels = [
409 + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" },
410 +]
411 +
412 +[[package]]
413 +name = "jupyter-core"
414 +version = "5.9.1"
415 +source = { registry = "https://pypi.org/simple" }
416 +dependencies = [
417 + { name = "platformdirs" },
418 + { name = "traitlets" },
419 +]
420 +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" }
421 +wheels = [
422 + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" },
423 +]
424 +
425 +[[package]]
426 +name = "jupyter-kernel-client"
427 +version = "0.8.0"
428 +source = { git = "https://github.com/googlecolab/jupyter-kernel-client.git#f18e982c3265df5e923aa9def101ab3fd737e139" }
429 +dependencies = [
430 + { name = "jupyter-client" },
431 + { name = "jupyter-core" },
432 + { name = "jupyter-mimetypes" },
433 + { name = "requests" },
434 + { name = "traitlets" },
435 + { name = "typing-extensions" },
436 + { name = "websocket-client" },
437 +]
438 +
439 +[[package]]
440 +name = "jupyter-mimetypes"
441 +version = "0.2.0"
442 +source = { registry = "https://pypi.org/simple" }
443 +dependencies = [
444 + { name = "pyarrow" },
445 + { name = "typing-extensions" },
446 +]
447 +wheels = [
448 + { url = "https://files.pythonhosted.org/packages/72/45/cb4671e13fed39f721066ad1a00714d4b607982b8d3e97a25f836198d1df/jupyter_mimetypes-0.2.0-py3-none-any.whl", hash = "sha256:e6dcd989258e3fc944365b656d9173191517e0e393bd878e97ce500e5b388527", size = 16724, upload-time = "2025-08-10T18:18:27.309Z" },
449 +]
450 +
451 +[[package]]
452 +name = "markdown-it-py"
453 +version = "4.0.0"
454 +source = { registry = "https://pypi.org/simple" }
455 +dependencies = [
456 + { name = "mdurl" },
457 +]
458 +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
459 +wheels = [
460 + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
461 +]
462 +
463 +[[package]]
464 +name = "mdurl"
465 +version = "0.1.2"
466 +source = { registry = "https://pypi.org/simple" }
467 +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
468 +wheels = [
469 + { url = "https://files.pythonhosted.org/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" },
470 +]
471 +
472 +[[package]]
473 +name = "nbformat"
474 +version = "5.10.4"
475 +source = { registry = "https://pypi.org/simple" }
476 +dependencies = [
477 + { name = "fastjsonschema" },
478 + { name = "jsonschema" },
479 + { name = "jupyter-core" },
480 + { name = "traitlets" },
481 +]
482 +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" }
483 +wheels = [
484 + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" },
485 +]
486 +
487 +[[package]]
488 +name = "oauthlib"
489 +version = "3.3.1"
490 +source = { registry = "https://pypi.org/simple" }
491 +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
492 +wheels = [
493 + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
494 +]
495 +
496 +[[package]]
497 +name = "packaging"
498 +version = "26.0"
499 +source = { registry = "https://pypi.org/simple" }
500 +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
501 +wheels = [
502 + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
503 +]
504 +
505 +[[package]]
506 +name = "platformdirs"
507 +version = "4.9.4"
508 +source = { registry = "https://pypi.org/simple" }
509 +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
510 +wheels = [
511 + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
512 +]
513 +
514 +[[package]]
515 +name = "pluggy"
516 +version = "1.6.0"
517 +source = { registry = "https://pypi.org/simple" }
518 +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
519 +wheels = [
520 + { url = "https://files.pythonhosted.org/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" },
521 +]
522 +
523 +[[package]]
524 +name = "prompt-toolkit"
525 +version = "3.0.52"
526 +source = { registry = "https://pypi.org/simple" }
527 +dependencies = [
528 + { name = "wcwidth" },
529 +]
530 +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
531 +wheels = [
532 + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
533 +]
534 +
535 +[[package]]
536 +name = "pyarrow"
537 +version = "23.0.1"
538 +source = { registry = "https://pypi.org/simple" }
539 +sdist = { url = "https://files.pythonhosted.org/packages/88/22/134986a4cc224d593c1afde5494d18ff629393d74cc2eddb176669f234a4/pyarrow-23.0.1.tar.gz", hash = "sha256:b8c5873e33440b2bc2f4a79d2b47017a89c5a24116c055625e6f2ee50523f019", size = 1167336, upload-time = "2026-02-16T10:14:12.39Z" }
540 +wheels = [
541 + { url = "https://files.pythonhosted.org/packages/47/10/2cbe4c6f0fb83d2de37249567373d64327a5e4d8db72f486db42875b08f6/pyarrow-23.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6b8fda694640b00e8af3c824f99f789e836720aa8c9379fb435d4c4953a756b8", size = 34210066, upload-time = "2026-02-16T10:10:45.487Z" },
542 + { url = "https://files.pythonhosted.org/packages/cb/4f/679fa7e84dadbaca7a65f7cdba8d6c83febbd93ca12fa4adf40ba3b6362b/pyarrow-23.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:8ff51b1addc469b9444b7c6f3548e19dc931b172ab234e995a60aea9f6e6025f", size = 35825526, upload-time = "2026-02-16T10:10:52.266Z" },
543 + { url = "https://files.pythonhosted.org/packages/f9/63/d2747d930882c9d661e9398eefc54f15696547b8983aaaf11d4a2e8b5426/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:71c5be5cbf1e1cb6169d2a0980850bccb558ddc9b747b6206435313c47c37677", size = 44473279, upload-time = "2026-02-16T10:11:01.557Z" },
544 + { url = "https://files.pythonhosted.org/packages/b3/93/10a48b5e238de6d562a411af6467e71e7aedbc9b87f8d3a35f1560ae30fb/pyarrow-23.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:9b6f4f17b43bc39d56fec96e53fe89d94bac3eb134137964371b45352d40d0c2", size = 47585798, upload-time = "2026-02-16T10:11:09.401Z" },
545 + { url = "https://files.pythonhosted.org/packages/5c/20/476943001c54ef078dbf9542280e22741219a184a0632862bca4feccd666/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fc13fc6c403d1337acab46a2c4346ca6c9dec5780c3c697cf8abfd5e19b6b37", size = 48179446, upload-time = "2026-02-16T10:11:17.781Z" },
546 + { url = "https://files.pythonhosted.org/packages/4b/b6/5dd0c47b335fcd8edba9bfab78ad961bd0fd55ebe53468cc393f45e0be60/pyarrow-23.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c16ed4f53247fa3ffb12a14d236de4213a4415d127fe9cebed33d51671113e2", size = 50623972, upload-time = "2026-02-16T10:11:26.185Z" },
547 + { url = "https://files.pythonhosted.org/packages/d5/09/a532297c9591a727d67760e2e756b83905dd89adb365a7f6e9c72578bcc1/pyarrow-23.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cecfb12ef629cf6be0b1887f9f86463b0dd3dc3195ae6224e74006be4736035a", size = 27540749, upload-time = "2026-02-16T10:12:23.297Z" },
548 + { url = "https://files.pythonhosted.org/packages/a5/8e/38749c4b1303e6ae76b3c80618f84861ae0c55dd3c2273842ea6f8258233/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:29f7f7419a0e30264ea261fdc0e5fe63ce5a6095003db2945d7cd78df391a7e1", size = 34471544, upload-time = "2026-02-16T10:11:32.535Z" },
549 + { url = "https://files.pythonhosted.org/packages/a3/73/f237b2bc8c669212f842bcfd842b04fc8d936bfc9d471630569132dc920d/pyarrow-23.0.1-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:33d648dc25b51fd8055c19e4261e813dfc4d2427f068bcecc8b53d01b81b0500", size = 35949911, upload-time = "2026-02-16T10:11:39.813Z" },
550 + { url = "https://files.pythonhosted.org/packages/0c/86/b912195eee0903b5611bf596833def7d146ab2d301afeb4b722c57ffc966/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:cd395abf8f91c673dd3589cadc8cc1ee4e8674fa61b2e923c8dd215d9c7d1f41", size = 44520337, upload-time = "2026-02-16T10:11:47.764Z" },
551 + { url = "https://files.pythonhosted.org/packages/69/c2/f2a717fb824f62d0be952ea724b4f6f9372a17eed6f704b5c9526f12f2f1/pyarrow-23.0.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:00be9576d970c31defb5c32eb72ef585bf600ef6d0a82d5eccaae96639cf9d07", size = 47548944, upload-time = "2026-02-16T10:11:56.607Z" },
552 + { url = "https://files.pythonhosted.org/packages/84/a7/90007d476b9f0dc308e3bc57b832d004f848fd6c0da601375d20d92d1519/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c2139549494445609f35a5cda4eb94e2c9e4d704ce60a095b342f82460c73a83", size = 48236269, upload-time = "2026-02-16T10:12:04.47Z" },
553 + { url = "https://files.pythonhosted.org/packages/b0/3f/b16fab3e77709856eb6ac328ce35f57a6d4a18462c7ca5186ef31b45e0e0/pyarrow-23.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7044b442f184d84e2351e5084600f0d7343d6117aabcbc1ac78eb1ae11eb4125", size = 50604794, upload-time = "2026-02-16T10:12:11.797Z" },
554 + { url = "https://files.pythonhosted.org/packages/e9/a1/22df0620a9fac31d68397a75465c344e83c3dfe521f7612aea33e27ab6c0/pyarrow-23.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a35581e856a2fafa12f3f54fce4331862b1cfb0bef5758347a858a4aa9d6bae8", size = 27660642, upload-time = "2026-02-16T10:12:17.746Z" },
555 + { url = "https://files.pythonhosted.org/packages/8d/1b/6da9a89583ce7b23ac611f183ae4843cd3a6cf54f079549b0e8c14031e73/pyarrow-23.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:5df1161da23636a70838099d4aaa65142777185cc0cdba4037a18cee7d8db9ca", size = 34238755, upload-time = "2026-02-16T10:12:32.819Z" },
556 + { url = "https://files.pythonhosted.org/packages/ae/b5/d58a241fbe324dbaeb8df07be6af8752c846192d78d2272e551098f74e88/pyarrow-23.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:fa8e51cb04b9f8c9c5ace6bab63af9a1f88d35c0d6cbf53e8c17c098552285e1", size = 35847826, upload-time = "2026-02-16T10:12:38.949Z" },
557 + { url = "https://files.pythonhosted.org/packages/54/a5/8cbc83f04aba433ca7b331b38f39e000efd9f0c7ce47128670e737542996/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b95a3994f015be13c63148fef8832e8a23938128c185ee951c98908a696e0eb", size = 44536859, upload-time = "2026-02-16T10:12:45.467Z" },
558 + { url = "https://files.pythonhosted.org/packages/36/2e/c0f017c405fcdc252dbccafbe05e36b0d0eb1ea9a958f081e01c6972927f/pyarrow-23.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4982d71350b1a6e5cfe1af742c53dfb759b11ce14141870d05d9e540d13bc5d1", size = 47614443, upload-time = "2026-02-16T10:12:55.525Z" },
559 + { url = "https://files.pythonhosted.org/packages/af/6b/2314a78057912f5627afa13ba43809d9d653e6630859618b0fd81a4e0759/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c250248f1fe266db627921c89b47b7c06fee0489ad95b04d50353537d74d6886", size = 48232991, upload-time = "2026-02-16T10:13:04.729Z" },
560 + { url = "https://files.pythonhosted.org/packages/40/f2/1bcb1d3be3460832ef3370d621142216e15a2c7c62602a4ea19ec240dd64/pyarrow-23.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f4763b83c11c16e5f4c15601ba6dfa849e20723b46aa2617cb4bffe8768479f", size = 50645077, upload-time = "2026-02-16T10:13:14.147Z" },
561 + { url = "https://files.pythonhosted.org/packages/eb/3f/b1da7b61cd66566a4d4c8383d376c606d1c34a906c3f1cb35c479f59d1aa/pyarrow-23.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:3a4c85ef66c134161987c17b147d6bffdca4566f9a4c1d81a0a01cdf08414ea5", size = 28234271, upload-time = "2026-02-16T10:14:09.397Z" },
562 + { url = "https://files.pythonhosted.org/packages/b5/78/07f67434e910a0f7323269be7bfbf58699bd0c1d080b18a1ab49ba943fe8/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:17cd28e906c18af486a499422740298c52d7c6795344ea5002a7720b4eadf16d", size = 34488692, upload-time = "2026-02-16T10:13:21.541Z" },
563 + { url = "https://files.pythonhosted.org/packages/50/76/34cf7ae93ece1f740a04910d9f7e80ba166b9b4ab9596a953e9e62b90fe1/pyarrow-23.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:76e823d0e86b4fb5e1cf4a58d293036e678b5a4b03539be933d3b31f9406859f", size = 35964383, upload-time = "2026-02-16T10:13:28.63Z" },
564 + { url = "https://files.pythonhosted.org/packages/46/90/459b827238936d4244214be7c684e1b366a63f8c78c380807ae25ed92199/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a62e1899e3078bf65943078b3ad2a6ddcacf2373bc06379aac61b1e548a75814", size = 44538119, upload-time = "2026-02-16T10:13:35.506Z" },
565 + { url = "https://files.pythonhosted.org/packages/28/a1/93a71ae5881e99d1f9de1d4554a87be37da11cd6b152239fb5bd924fdc64/pyarrow-23.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:df088e8f640c9fae3b1f495b3c64755c4e719091caf250f3a74d095ddf3c836d", size = 47571199, upload-time = "2026-02-16T10:13:42.504Z" },
566 + { url = "https://files.pythonhosted.org/packages/88/a3/d2c462d4ef313521eaf2eff04d204ac60775263f1fb08c374b543f79f610/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:46718a220d64677c93bc243af1d44b55998255427588e400677d7192671845c7", size = 48259435, upload-time = "2026-02-16T10:13:49.226Z" },
567 + { url = "https://files.pythonhosted.org/packages/cc/f1/11a544b8c3d38a759eb3fbb022039117fd633e9a7b19e4841cc3da091915/pyarrow-23.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a09f3876e87f48bc2f13583ab551f0379e5dfb83210391e68ace404181a20690", size = 50629149, upload-time = "2026-02-16T10:13:57.238Z" },
568 + { url = "https://files.pythonhosted.org/packages/50/f2/c0e76a0b451ffdf0cf788932e182758eb7558953f4f27f1aff8e2518b653/pyarrow-23.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:527e8d899f14bd15b740cd5a54ad56b7f98044955373a17179d5956ddb93d9ce", size = 28365807, upload-time = "2026-02-16T10:14:03.892Z" },
569 +]
570 +
571 +[[package]]
572 +name = "pyasn1"
573 +version = "0.6.2"
574 +source = { registry = "https://pypi.org/simple" }
575 +sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/6e630dff89739fcd427e3f72b3d905ce0acb85a45d4ec3e2678718a3487f/pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b", size = 146586, upload-time = "2026-01-16T18:04:18.534Z" }
576 +wheels = [
577 + { url = "https://files.pythonhosted.org/packages/44/b5/a96872e5184f354da9c84ae119971a0a4c221fe9b27a4d94bd43f2596727/pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf", size = 83371, upload-time = "2026-01-16T18:04:17.174Z" },
578 +]
579 +
580 +[[package]]
581 +name = "pyasn1-modules"
582 +version = "0.4.2"
583 +source = { registry = "https://pypi.org/simple" }
584 +dependencies = [
585 + { name = "pyasn1" },
586 +]
587 +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
588 +wheels = [
589 + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
590 +]
591 +
592 +[[package]]
593 +name = "pycparser"
594 +version = "3.0"
595 +source = { registry = "https://pypi.org/simple" }
596 +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
597 +wheels = [
598 + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
599 +]
600 +
601 +[[package]]
602 +name = "pydantic"
603 +version = "2.12.5"
604 +source = { registry = "https://pypi.org/simple" }
605 +dependencies = [
606 + { name = "annotated-types" },
607 + { name = "pydantic-core" },
608 + { name = "typing-extensions" },
609 + { name = "typing-inspection" },
610 +]
611 +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
612 +wheels = [
613 + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
614 +]
615 +
616 +[[package]]
617 +name = "pydantic-core"
618 +version = "2.41.5"
619 +source = { registry = "https://pypi.org/simple" }
620 +dependencies = [
621 + { name = "typing-extensions" },
622 +]
623 +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
624 +wheels = [
625 + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
626 + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
627 + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
628 + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
629 + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
630 + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
631 + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
632 + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
633 + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
634 + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
635 + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
636 + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
637 + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
638 + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
639 + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
640 + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
641 + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
642 + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
643 + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
644 + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
645 + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
646 + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
647 + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
648 + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
649 + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
650 + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
651 + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
652 + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
653 + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
654 + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
655 + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
656 + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
657 + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
658 + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
659 + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
660 + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
661 + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
662 + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
663 + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
664 + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
665 + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
666 + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
667 +]
668 +
669 +[[package]]
670 +name = "pygments"
671 +version = "2.19.2"
672 +source = { registry = "https://pypi.org/simple" }
673 +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
674 +wheels = [
675 + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
676 +]
677 +
678 +[[package]]
679 +name = "pytest"
680 +version = "9.0.2"
681 +source = { registry = "https://pypi.org/simple" }
682 +dependencies = [
683 + { name = "colorama", marker = "sys_platform == 'win32'" },
684 + { name = "iniconfig" },
685 + { name = "packaging" },
686 + { name = "pluggy" },
687 + { name = "pygments" },
688 +]
689 +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
690 +wheels = [
691 + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
692 +]
693 +
694 +[[package]]
695 +name = "pytest-cov"
696 +version = "7.0.0"
697 +source = { registry = "https://pypi.org/simple" }
698 +dependencies = [
699 + { name = "coverage" },
700 + { name = "pluggy" },
701 + { name = "pytest" },
702 +]
703 +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
704 +wheels = [
705 + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
706 +]
707 +
708 +[[package]]
709 +name = "pytest-mock"
710 +version = "3.15.1"
711 +source = { registry = "https://pypi.org/simple" }
712 +dependencies = [
713 + { name = "pytest" },
714 +]
715 +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
716 +wheels = [
717 + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
718 +]
719 +
720 +[[package]]
721 +name = "python-dateutil"
722 +version = "2.9.0.post0"
723 +source = { registry = "https://pypi.org/simple" }
724 +dependencies = [
725 + { name = "six" },
726 +]
727 +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
728 +wheels = [
729 + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
730 +]
731 +
732 +[[package]]
733 +name = "pyzmq"
734 +version = "27.1.0"
735 +source = { registry = "https://pypi.org/simple" }
736 +dependencies = [
737 + { name = "cffi", marker = "implementation_name == 'pypy'" },
738 +]
739 +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
740 +wheels = [
741 + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
742 + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
743 + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
744 + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
745 + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
746 + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
747 + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
748 + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
749 + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
750 + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
751 + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
752 + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
753 + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
754 + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
755 + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
756 + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
757 + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
758 + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
759 + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
760 + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
761 + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
762 + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
763 + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" },
764 + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" },
765 + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" },
766 + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" },
767 + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" },
768 + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" },
769 + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" },
770 + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" },
771 + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" },
772 + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" },
773 +]
774 +
775 +[[package]]
776 +name = "referencing"
777 +version = "0.37.0"
778 +source = { registry = "https://pypi.org/simple" }
779 +dependencies = [
780 + { name = "attrs" },
781 + { name = "rpds-py" },
782 +]
783 +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
784 +wheels = [
785 + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
786 +]
787 +
788 +[[package]]
789 +name = "requests"
790 +version = "2.32.5"
791 +source = { registry = "https://pypi.org/simple" }
792 +dependencies = [
793 + { name = "certifi" },
794 + { name = "charset-normalizer" },
795 + { name = "idna" },
796 + { name = "urllib3" },
797 +]
798 +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
799 +wheels = [
800 + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
801 +]
802 +
803 +[[package]]
804 +name = "requests-oauthlib"
805 +version = "2.0.0"
806 +source = { registry = "https://pypi.org/simple" }
807 +dependencies = [
808 + { name = "oauthlib" },
809 + { name = "requests" },
810 +]
811 +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
812 +wheels = [
813 + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
814 +]
815 +
816 +[[package]]
817 +name = "rich"
818 +version = "14.3.3"
819 +source = { registry = "https://pypi.org/simple" }
820 +dependencies = [
821 + { name = "markdown-it-py" },
822 + { name = "pygments" },
823 +]
824 +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
825 +wheels = [
826 + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
827 +]
828 +
829 +[[package]]
830 +name = "rpds-py"
831 +version = "0.30.0"
832 +source = { registry = "https://pypi.org/simple" }
833 +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
834 +wheels = [
835 + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
836 + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
837 + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
838 + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
839 + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
840 + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
841 + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
842 + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
843 + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
844 + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
845 + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
846 + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
847 + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
848 + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
849 + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
850 + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
851 + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
852 + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
853 + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
854 + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
855 + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
856 + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
857 + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
858 + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
859 + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
860 + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
861 + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
862 + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
863 + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
864 + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
865 + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
866 + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
867 + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
868 + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
869 + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
870 + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
871 + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
872 + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
873 + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
874 + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
875 + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
876 + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
877 + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
878 + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
879 + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
880 + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
881 + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
882 + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
883 + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
884 + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
885 + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
886 + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
887 + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
888 + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
889 + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
890 + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
891 + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
892 + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
893 +]
894 +
895 +[[package]]
896 +name = "ruff"
897 +version = "0.15.6"
898 +source = { registry = "https://pypi.org/simple" }
899 +sdist = { url = "https://files.pythonhosted.org/packages/51/df/f8629c19c5318601d3121e230f74cbee7a3732339c52b21daa2b82ef9c7d/ruff-0.15.6.tar.gz", hash = "sha256:8394c7bb153a4e3811a4ecdacd4a8e6a4fa8097028119160dffecdcdf9b56ae4", size = 4597916, upload-time = "2026-03-12T23:05:47.51Z" }
900 +wheels = [
901 + { url = "https://files.pythonhosted.org/packages/9e/2f/4e03a7e5ce99b517e98d3b4951f411de2b0fa8348d39cf446671adcce9a2/ruff-0.15.6-py3-none-linux_armv6l.whl", hash = "sha256:7c98c3b16407b2cf3d0f2b80c80187384bc92c6774d85fefa913ecd941256fff", size = 10508953, upload-time = "2026-03-12T23:05:17.246Z" },
902 + { url = "https://files.pythonhosted.org/packages/70/60/55bcdc3e9f80bcf39edf0cd272da6fa511a3d94d5a0dd9e0adf76ceebdb4/ruff-0.15.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ee7dcfaad8b282a284df4aa6ddc2741b3f4a18b0555d626805555a820ea181c3", size = 10942257, upload-time = "2026-03-12T23:05:23.076Z" },
903 + { url = "https://files.pythonhosted.org/packages/e7/f9/005c29bd1726c0f492bfa215e95154cf480574140cb5f867c797c18c790b/ruff-0.15.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3bd9967851a25f038fc8b9ae88a7fbd1b609f30349231dffaa37b6804923c4bb", size = 10322683, upload-time = "2026-03-12T23:05:33.738Z" },
904 + { url = "https://files.pythonhosted.org/packages/5f/74/2f861f5fd7cbb2146bddb5501450300ce41562da36d21868c69b7a828169/ruff-0.15.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13f4594b04e42cd24a41da653886b04d2ff87adbf57497ed4f728b0e8a4866f8", size = 10660986, upload-time = "2026-03-12T23:05:53.245Z" },
905 + { url = "https://files.pythonhosted.org/packages/c1/a1/309f2364a424eccb763cdafc49df843c282609f47fe53aa83f38272389e0/ruff-0.15.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2ed8aea2f3fe57886d3f00ea5b8aae5bf68d5e195f487f037a955ff9fbaac9e", size = 10332177, upload-time = "2026-03-12T23:05:56.145Z" },
906 + { url = "https://files.pythonhosted.org/packages/30/41/7ebf1d32658b4bab20f8ac80972fb19cd4e2c6b78552be263a680edc55ac/ruff-0.15.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70789d3e7830b848b548aae96766431c0dc01a6c78c13381f423bf7076c66d15", size = 11170783, upload-time = "2026-03-12T23:06:01.742Z" },
907 + { url = "https://files.pythonhosted.org/packages/76/be/6d488f6adca047df82cd62c304638bcb00821c36bd4881cfca221561fdfc/ruff-0.15.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:542aaf1de3154cea088ced5a819ce872611256ffe2498e750bbae5247a8114e9", size = 12044201, upload-time = "2026-03-12T23:05:28.697Z" },
908 + { url = "https://files.pythonhosted.org/packages/71/68/e6f125df4af7e6d0b498f8d373274794bc5156b324e8ab4bf5c1b4fc0ec7/ruff-0.15.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c22e6f02c16cfac3888aa636e9eba857254d15bbacc9906c9689fdecb1953ab", size = 11421561, upload-time = "2026-03-12T23:05:31.236Z" },
909 + { url = "https://files.pythonhosted.org/packages/f1/9f/f85ef5fd01a52e0b472b26dc1b4bd228b8f6f0435975442ffa4741278703/ruff-0.15.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98893c4c0aadc8e448cfa315bd0cc343a5323d740fe5f28ef8a3f9e21b381f7e", size = 11310928, upload-time = "2026-03-12T23:05:45.288Z" },
910 + { url = "https://files.pythonhosted.org/packages/8c/26/b75f8c421f5654304b89471ed384ae8c7f42b4dff58fa6ce1626d7f2b59a/ruff-0.15.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:70d263770d234912374493e8cc1e7385c5d49376e41dfa51c5c3453169dc581c", size = 11235186, upload-time = "2026-03-12T23:05:50.677Z" },
911 + { url = "https://files.pythonhosted.org/packages/fc/d4/d5a6d065962ff7a68a86c9b4f5500f7d101a0792078de636526c0edd40da/ruff-0.15.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:55a1ad63c5a6e54b1f21b7514dfadc0c7fb40093fa22e95143cf3f64ebdcd512", size = 10635231, upload-time = "2026-03-12T23:05:37.044Z" },
912 + { url = "https://files.pythonhosted.org/packages/d6/56/7c3acf3d50910375349016cf33de24be021532042afbed87942858992491/ruff-0.15.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8dc473ba093c5ec238bb1e7429ee676dca24643c471e11fbaa8a857925b061c0", size = 10340357, upload-time = "2026-03-12T23:06:04.748Z" },
913 + { url = "https://files.pythonhosted.org/packages/06/54/6faa39e9c1033ff6a3b6e76b5df536931cd30caf64988e112bbf91ef5ce5/ruff-0.15.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:85b042377c2a5561131767974617006f99f7e13c63c111b998f29fc1e58a4cfb", size = 10860583, upload-time = "2026-03-12T23:05:58.978Z" },
914 + { url = "https://files.pythonhosted.org/packages/cb/1e/509a201b843b4dfb0b32acdedf68d951d3377988cae43949ba4c4133a96a/ruff-0.15.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:cef49e30bc5a86a6a92098a7fbf6e467a234d90b63305d6f3ec01225a9d092e0", size = 11410976, upload-time = "2026-03-12T23:05:39.955Z" },
915 + { url = "https://files.pythonhosted.org/packages/6c/25/3fc9114abf979a41673ce877c08016f8e660ad6cf508c3957f537d2e9fa9/ruff-0.15.6-py3-none-win32.whl", hash = "sha256:bbf67d39832404812a2d23020dda68fee7f18ce15654e96fb1d3ad21a5fe436c", size = 10616872, upload-time = "2026-03-12T23:05:42.451Z" },
916 + { url = "https://files.pythonhosted.org/packages/89/7a/09ece68445ceac348df06e08bf75db72d0e8427765b96c9c0ffabc1be1d9/ruff-0.15.6-py3-none-win_amd64.whl", hash = "sha256:aee25bc84c2f1007ecb5037dff75cef00414fdf17c23f07dc13e577883dca406", size = 11787271, upload-time = "2026-03-12T23:05:20.168Z" },
917 + { url = "https://files.pythonhosted.org/packages/7f/d0/578c47dd68152ddddddf31cd7fc67dc30b7cdf639a86275fda821b0d9d98/ruff-0.15.6-py3-none-win_arm64.whl", hash = "sha256:c34de3dd0b0ba203be50ae70f5910b17188556630e2178fd7d79fc030eb0d837", size = 11060497, upload-time = "2026-03-12T23:05:25.968Z" },
918 +]
919 +
920 +[[package]]
921 +name = "shellingham"
922 +version = "1.5.4"
923 +source = { registry = "https://pypi.org/simple" }
924 +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
925 +wheels = [
926 + { url = "https://files.pythonhosted.org/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" },
927 +]
928 +
929 +[[package]]
930 +name = "six"
931 +version = "1.17.0"
932 +source = { registry = "https://pypi.org/simple" }
933 +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
934 +wheels = [
935 + { url = "https://files.pythonhosted.org/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" },
936 +]
937 +
938 +[[package]]
939 +name = "tornado"
940 +version = "6.5.5"
941 +source = { registry = "https://pypi.org/simple" }
942 +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
943 +wheels = [
944 + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
945 + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
946 + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
947 + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
948 + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
949 + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
950 + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
951 + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
952 + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
953 +]
954 +
955 +[[package]]
956 +name = "traitlets"
957 +version = "5.14.3"
958 +source = { registry = "https://pypi.org/simple" }
959 +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" }
960 +wheels = [
961 + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" },
962 +]
963 +
964 +[[package]]
965 +name = "typer"
966 +version = "0.24.1"
967 +source = { registry = "https://pypi.org/simple" }
968 +dependencies = [
969 + { name = "annotated-doc" },
970 + { name = "click" },
971 + { name = "rich" },
972 + { name = "shellingham" },
973 +]
974 +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
975 +wheels = [
976 + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
977 +]
978 +
979 +[[package]]
980 +name = "typing-extensions"
981 +version = "4.15.0"
982 +source = { registry = "https://pypi.org/simple" }
983 +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
984 +wheels = [
985 + { url = "https://files.pythonhosted.org/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" },
986 +]
987 +
988 +[[package]]
989 +name = "typing-inspection"
990 +version = "0.4.2"
991 +source = { registry = "https://pypi.org/simple" }
992 +dependencies = [
993 + { name = "typing-extensions" },
994 +]
995 +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
996 +wheels = [
997 + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
998 +]
999 +
1000 +[[package]]
1001 +name = "urllib3"
1002 +version = "2.6.3"
1003 +source = { registry = "https://pypi.org/simple" }
1004 +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
1005 +wheels = [
1006 + { url = "https://files.pythonhosted.org/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" },
1007 +]
1008 +
1009 +[[package]]
1010 +name = "wcwidth"
1011 +version = "0.6.0"
1012 +source = { registry = "https://pypi.org/simple" }
1013 +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
1014 +wheels = [
1015 + { url = "https://files.pythonhosted.org/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" },
1016 +]
1017 +
1018 +[[package]]
1019 +name = "websocket-client"
1020 +version = "1.9.0"
1021 +source = { registry = "https://pypi.org/simple" }
1022 +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
1023 +wheels = [
1024 + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
1025 +]