From 3fc1fe326590d8b6f4e4683c2774816bc474042f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 18:29:37 -0400 Subject: [PATCH 01/43] docs: add design spec for Python API and API tokens Specs a scoped bearer-token auth mechanism layered onto the existing get_current_user dependency, GUI token management, and a path-level Python client shipped inside the fileglancer package. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-24-python-api-design.md | 302 ++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-24-python-api-design.md diff --git a/docs/superpowers/specs/2026-08-24-python-api-design.md b/docs/superpowers/specs/2026-08-24-python-api-design.md new file mode 100644 index 000000000..ec52f5e86 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-python-api-design.md @@ -0,0 +1,302 @@ +# Python API and API Tokens — Design + +Status: approved, not yet implemented +Date: 2026-08-24 + +## Goal + +Let users drive Fileglancer programmatically: create an API token in the GUI, then use it from a Python script or notebook to browse files, create data links, and turn those data links into Neuroglancer links. + +The motivating workflow is: create a data link for a folder, build a Neuroglancer state from it with the `neuroglancer` Python package, and shorten that state into an NG link. + +This adds **one new auth mechanism** and **one new client library**. It does not add, duplicate, or reshape any existing REST endpoint. Everything the Python client does, it does by calling the same HTTP API the web UI calls. + +## Non-goals + +- OAuth device flow or any interactive login from the client. +- Per-token rate limiting. +- Scopes for apps, catalog, tickets, preferences, or SSH keys — those stay browser-session-only. +- An async client. +- A separate `fileglancer-client` distribution. The client ships inside the `fileglancer` package alongside the CLI. + +--- + +## 1. Token storage and verification + +### Table + +New table `api_tokens`, added by an Alembic migration whose `down_revision` is `e7b2a9c4f130` (the current head, `add_name_to_jobs`). + +| column | type | notes | +| --- | --- | --- | +| `id` | Integer PK | autoincrement | +| `token_id` | String | 12-char public identifier, unique, indexed | +| `token_hash` | String | SHA-256 hex of the secret half | +| `username` | String | owner, indexed | +| `name` | String | user-supplied label, e.g. "laptop notebook" | +| `scopes` | String | space-separated, e.g. `files:read links:write` | +| `created_at` | DateTime | | +| `expires_at` | DateTime | non-null; see lifetime rules | +| `last_used_at` | DateTime | nullable | + +Follows the `SessionDB` pattern in `fileglancer/database.py` — a model class plus module-level `create_api_token` / `get_api_token_by_id` / `list_api_tokens` / `delete_api_token` / `touch_api_token` functions. + +### Token format + +``` +fgt__ +``` + +`token_id` is 12 characters, `secret` is `secrets.token_urlsafe(32)`. The `token_id` gives an indexed single-row lookup; the secret is then compared with `hmac.compare_digest(sha256(secret), row.token_hash)`. + +The full token string is returned exactly once, from `POST /api/tokens`. It is never stored and never recoverable. + +> `ponytail:` SHA-256 rather than bcrypt/argon2. The secret is 32 bytes of `secrets` entropy, not a human-chosen password, so a slow KDF defends against nothing that matters here. Upgrade to a KDF only if user-chosen token secrets ever become a thing. + +### Lifetime + +`expires_at` is always set. The client picks the window at creation time; the server validates it. + +- Default: 30 days. +- Maximum: 365 days. A request above the maximum is rejected with 400. +- No "never expires" option. + +Verification rejects an expired token with 401 and a message naming the expiry date, so the failure is self-explanatory in a script's traceback. Expired rows are not auto-deleted; they stay visible in the GUI list marked expired until the user revokes them. + +### Where it plugs in + +`auth.get_current_user(request, settings)` in `fileglancer/auth.py:170` grows a single branch: + +1. If an `Authorization: Bearer fgt_…` header is present, resolve it to a username via a new `auth.get_user_from_token()`, enforce scope (section 2), and return the username. +2. Otherwise, fall back to the existing cookie path unchanged. + +Because every one of the ~55 authenticated routes already goes through the single `Depends(get_current_user)` dependency at `fileglancer/server.py:173`, no route needs editing. Token auth resolves to a username, and everything downstream — per-user worker dispatch, setuid file access, ownership checks on data links — behaves exactly as it does for a browser session. + +`server.get_current_user` skips `auth.enforce_request_origin` when the request carries a bearer token. Origin enforcement exists to stop a same-site page from riding an ambient session cookie; a bearer token is not ambient, and a script sends no `Origin` header at all. + +`last_used_at` is written only when the stored value is more than 5 minutes stale, so token auth does not cost a database write per request. + +--- + +## 2. Scopes + +Six scopes across three resources. `:write` implies `:read`. + +``` +files:read files:write +links:read links:write +jobs:read jobs:write +``` + +### Enforcement + +Annotating 55 routes with a `require_scope` dependency would be 55 places to get wrong. Instead there is one path-prefix table, consulted in the same place the token is resolved — it has both the request path and the method available. + +| prefix | resource | +| --- | --- | +| `/api/files`, `/api/content` | `files` | +| `/api/proxied-path`, `/api/neuroglancer` | `links` | +| `/api/jobs`, `/api/cluster-defaults` | `jobs` | +| `/api/profile`, `/api/auth/status` | any valid token | +| anything else | 403 | + +`GET` and `HEAD` require `:read`; every other method requires `:write`. + +`/api/file-share-paths`, `/api/external-buckets`, and `/api/version` are deliberately absent: they have no `Depends(get_current_user)` today and are already fully unauthenticated, so the scope check never runs for them. This matters for the client, whose path resolution depends on `/api/file-share-paths` — resolution works regardless of which scopes a token carries. + +Deny-by-default is what makes this safe. It is why `/api/ssh-keys`, `/api/tokens`, `/api/apps`, `/api/catalog`, `/api/preference`, and `/api/ticket` are unreachable by token without any of them being named — including the important case that **a token cannot mint another token**. + +The table is consulted only for bearer auth. Cookie sessions are entirely unaffected. + +### Guarding against drift + +A test enumerates `app.routes` and asserts that every `/api/*` path that depends on `get_current_user` either matches a prefix in the table or appears on an explicit session-only list. A new route added later cannot silently become token-reachable, and cannot silently break for token users either — one of the two lists must be updated, deliberately. + +--- + +## 3. Token management: endpoints and GUI + +### Endpoints + +- `GET /api/tokens` — list the caller's tokens. Returns `token_id`, `name`, `scopes`, `created_at`, `expires_at`, `last_used_at`. Never returns a secret or a hash. +- `POST /api/tokens` — body `{name, scopes, expires_in_days}`. Validates the scope names against the known six and `expires_in_days` against the 365-day maximum. Returns the created token record **plus** the one-time plaintext token. +- `DELETE /api/tokens/{token_id}` — revoke. + +These are cookie-only by virtue of deny-by-default; no extra guard is needed. + +### GUI + +A new page at `/api-tokens`, registered in `frontend/src/App.tsx` next to the existing `ssh-keys` route, built on the existing `TableCard` pattern. `TableCard`'s `DataType` union needs the new row type added. + +The create dialog takes a name, scope checkboxes, and an expiry selector (30 / 90 / 365 days, defaulting to 30). On success it shows the token once, with a copy button and a ready-to-paste snippet: + +```bash +export FILEGLANCER_URL=https://fileglancer.int.janelia.org +export FILEGLANCER_TOKEN=fgt_a1b2c3d4e5f6_... +``` + +The dialog states plainly that the token will not be shown again. This mirrors the existing `GenerateTempKeyDialog` / `TempKeyDialog` flow in `frontend/src/components/ui/SSHKeys/`, which already solves "show a secret exactly once." + +--- + +## 4. Python client + +Lives at `fileglancer/client.py`, exported as `from fileglancer import Fileglancer`. A hand-written wrapper over `httpx`, which is already a dependency — no new dependencies, no optional extras, no separate package. + +### Construction + +```python +Fileglancer(url=None, token=None) +``` + +Falls back to the `FILEGLANCER_URL` and `FILEGLANCER_TOKEN` environment variables. Nothing is read from or written to disk, which keeps it working unchanged inside cluster jobs and containers. + +### Paths are absolute UNIX paths + +The client's entire surface operates at the level the user sees on the filesystem, not at Fileglancer's internal `(fsp_name, relative_path)` level. Resolution is the client's job and happens entirely client-side. + +`/api/file-share-paths` (`fileglancer/server.py:828`) is already unauthenticated and already returns every mount form for every share. The client fetches it once, caches it, and matches with pure string operations. This requires **zero server changes** and adds nothing to the REST API. + +The algorithm mirrors the existing frontend resolver `resolvePathToFsp` in `frontend/src/utils/pathHandling.ts:227`, which has been solving this same problem for the navigation bar and file selector: + +1. Normalize backslashes to forward slashes. +2. For each file share path, consider `mount_path`, `linux_path`, `mac_path`, and `windows_path` as candidate prefixes. +3. Keep the longest candidate that the input starts with, where the remainder is either empty or begins with `/`. The remainder check is what stops `/misc/public` from matching `/misc/public-archive`. +4. The remainder, with any leading slash stripped, is the FSP-relative path. + +Accepting all four mount forms means a path pasted from a Mac Finder window or a Windows UNC share resolves just as well as a cluster path. + +No match raises `FileglancerError` with a message listing the available mount points. This error message is load-bearing — it is the difference between a usable library and an infuriating one. + +`fg.refresh()` busts the cache. `fg.file_share_paths()` exposes the underlying list. + +### Methods + +Each maps to exactly one existing endpoint and parses the response into the existing Pydantic types from `fileglancer.model` and `fileglancer.filestore`. The client re-describes no data structures. Note that `/api/files` returns a raw worker dict rather than a declared `response_model`, so the client does that parsing itself against `filestore.FileInfo`. + +Files: + +```python +fg.ls(path) # GET /api/files +fg.mkdir(path) # POST /api/files +fg.rename(src, dst) # PATCH /api/files +fg.delete(path) # DELETE /api/files +fg.read(path) # GET /api/content +fg.write(path, data) # PUT /api/content +fg.file_share_paths() # GET /api/file-share-paths +``` + +Data links: + +```python +fg.create_data_link(path, url_prefix=None) +fg.data_links() +fg.data_link(sharing_key) +fg.delete_data_link(sharing_key) +``` + +Neuroglancer links: + +```python +fg.create_ng_link(state, url_base=NEUROGLANCER_URL, title=None, short_name=None) +fg.ng_links() +fg.delete_ng_link(short_key) +``` + +Jobs: + +```python +fg.jobs() +fg.submit_job(...) +fg.cancel_job(job_id) +``` + +`rename` resolves both sides and rejects a cross-share move up front with a clear message, because the underlying `PATCH /api/files` cannot perform one. + +`NEUROGLANCER_URL` is a module constant, `https://neuroglancer-demo.appspot.com`. The server has no configured default Neuroglancer base URL, and `POST /api/neuroglancer/nglinks` requires `url_base` when given a state directly, so the default has to live in the client. + +`create_ng_link` takes a plain dict. That is precisely what `neuroglancer.ViewerState.to_json()` produces, so the useful integration costs the client no dependency on the `neuroglancer` package and no ownership of Neuroglancer layer-type heuristics. + +### Outputs are absolute too + +`ls()` needs no work: `FileInfo.absolute_path` (`fileglancer/filestore.py:44`) is already populated by `from_stat` and already passed through `/api/files` untouched. + +`ProxiedPath.path` is FSP-relative as the REST API defines it, so the client returns `p.model_copy(update={"path": absolute_path})` — same type, absolute value, one line. `fsp_name` remains on the object for reference. + +One wrinkle to document rather than code around: returned paths are always in `mount_path` form. A Windows or SMB path fed to the client resolves correctly, but what comes back is the Linux path. + +### Errors + +Any non-2xx response raises `FileglancerError` carrying the status code and the API's `detail` string. + +### Target workflow + +```python +import neuroglancer +from fileglancer import Fileglancer + +fg = Fileglancer() +link = fg.create_data_link("/nearline/rokickik/sample.zarr") + +state = neuroglancer.ViewerState() +state.layers["sample"] = neuroglancer.ImageLayer(source=f"zarr://{link.url}") + +print(fg.create_ng_link(state.to_json(), title="sample")) +# https://neuroglancer-demo.appspot.com#!https://fileglancer.../ng/aB3xK9 +``` + +--- + +## 5. Testing + +Backend unit tests (`tests/`): + +- Token hash round-trip; `hmac.compare_digest` used for the comparison. +- Expired token rejected with 401 and an expiry date in the message. +- Malformed and unknown tokens rejected with 401. +- `expires_in_days` above 365 rejected with 400; default is 30. +- `:write` satisfies a `:read` requirement; `:read` does not satisfy `:write`. +- The route-coverage assertion from section 2. + +Integration tests via `TestClient`: + +- A token scoped `files:read` can `GET /api/files` and is refused `POST /api/files`. +- A token of any scope is refused `/api/ssh-keys` and `/api/tokens`. +- A cookie session is unaffected by all of the above. + +Client tests, run against the app through `httpx.ASGITransport` — real client code, no server process: + +- Path resolution, table-driven across all four mount forms. +- Longest-prefix tie-break: `/misc/public` versus `/misc/public-archive`. +- No-match error text names the available mount points. +- Cross-share rename rejected before any HTTP call. +- A shared fixture set asserting the Python and TypeScript resolvers agree. + +One Playwright spec in `frontend/ui-tests/tests/`: create a token, confirm the secret is shown once, revoke it. + +--- + +## 6. Documentation + +A user-facing page in the separate docs site at `../fileglancer-docs`, covering token creation in the GUI, the two environment variables, the method reference, and the Neuroglancer workflow above. + +--- + +## Files touched + +Backend: + +- `fileglancer/database.py` — `ApiTokenDB` model and CRUD functions. +- `fileglancer/alembic/versions/.py` — migration, `down_revision = 'e7b2a9c4f130'`. +- `fileglancer/auth.py` — `get_user_from_token()`, bearer branch in `get_current_user()`, scope table and check. +- `fileglancer/server.py` — three `/api/tokens` routes; skip origin enforcement for bearer auth. +- `fileglancer/model.py` — request and response models for the token endpoints. +- `fileglancer/client.py` — new; the Python client. +- `fileglancer/__init__.py` — export `Fileglancer` and `FileglancerError`. + +Frontend: + +- `frontend/src/App.tsx` — `/api-tokens` route. +- `frontend/src/components/ui/ApiTokens/` — new; list card and create dialog. +- `frontend/src/queries/` — token queries. +- `frontend/src/components/ui/Table/TableCard.tsx` — add the new row type to `DataType`. From bad95ca9c666e1c6a1994c489b957fd92daac826 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 18:44:03 -0400 Subject: [PATCH 02/43] docs: add implementation plan for Python API and API tokens Thirteen TDD tasks: token storage and migration, scope table, bearer auth wiring, management endpoints, the Python client in four slices, the GUI page and dialogs, an E2E spec, and user docs. Co-Authored-By: Claude Opus 5 (1M context) --- .../plans/2026-08-24-python-api-tokens.md | 3382 +++++++++++++++++ 1 file changed, 3382 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-24-python-api-tokens.md diff --git a/docs/superpowers/plans/2026-08-24-python-api-tokens.md b/docs/superpowers/plans/2026-08-24-python-api-tokens.md new file mode 100644 index 000000000..10eb8bc61 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-python-api-tokens.md @@ -0,0 +1,3382 @@ +# Python API and API Tokens Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users mint scoped API tokens in the Fileglancer GUI and use them from a Python client that operates on absolute UNIX paths. + +**Architecture:** Bearer-token auth is layered into the single `auth.get_current_user()` function that every authenticated route already depends on, so no route is edited. A path-prefix table maps request paths to scopes, deny-by-default. The Python client is a thin `httpx` wrapper that resolves absolute paths to `(fsp_name, relative_path)` entirely client-side, so the REST API gains nothing but the three token-management endpoints. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0 (sync), Alembic, Pydantic v2, httpx, pytest; React 18 + TypeScript + TanStack Query + Material Tailwind v3 on the frontend. + +**Spec:** `docs/superpowers/specs/2026-08-24-python-api-design.md` + +## Global Constraints + +- **Always use pixi.** Never run `pytest`, `npm`, or `npx` directly. Backend tests: `pixi run -e test test-backend`. Frontend checks: `pixi run node-check`, `pixi run node-eslint-write`, `pixi run node-prettier-write`. +- **No new Python dependencies.** `httpx` is already in `pyproject.toml` and is the only HTTP library the client may use. +- **No Janelia-specific names** in `fileglancer` code or comments. +- Token scope names are exactly: `files:read`, `files:write`, `links:read`, `links:write`, `jobs:read`, `jobs:write`. +- Token expiry: default 30 days, minimum 1 day, maximum 365 days. There is no "never expires" option. +- Token string format is exactly `fgt__` where `token_id` is 12 characters. +- The new Alembic migration's `down_revision` is `'e7b2a9c4f130'`. +- Never hard-wrap prose in Markdown files: one line per paragraph. +- SQLAlchemy does not preserve timezone info. Every `DateTime` read back from the database must have `.replace(tzinfo=UTC)` applied before comparison, matching `fileglancer/auth.py:78`. +- Run `pixi run node-eslint-write` and `pixi run node-prettier-write` after every frontend task. + +--- + +### Task 1: Token database model, CRUD, and migration + +**Files:** +- Modify: `fileglancer/database.py` (add model near `SessionDB` at line 260; add functions near the session functions at line 940) +- Create: `fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py` +- Test: `tests/test_api_tokens.py` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `db.ApiTokenDB` with columns `id, token_id, token_hash, username, name, scopes, created_at, expires_at, last_used_at` + - `db.hash_token_secret(secret: str) -> str` + - `db.create_api_token(session, username: str, name: str, scopes: list[str], expires_in_days: int = 30) -> tuple[ApiTokenDB, str]` returning `(row, plaintext_token)` + - `db.get_api_token_by_id(session, token_id: str) -> Optional[ApiTokenDB]` + - `db.list_api_tokens(session, username: str) -> list[ApiTokenDB]` + - `db.delete_api_token(session, username: str, token_id: str) -> int` + - `db.touch_api_token(session, token_id: str) -> None` + - Constants `db.API_TOKEN_PREFIX = "fgt"`, `db.DEFAULT_TOKEN_EXPIRY_DAYS = 30`, `db.MAX_TOKEN_EXPIRY_DAYS = 365`, `db.TOKEN_TOUCH_INTERVAL_SECONDS = 300` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_api_tokens.py`: + +```python +"""Tests for API token storage and CRUD.""" +from datetime import datetime, timedelta, UTC + +import pytest + +from fileglancer.database import ( + ApiTokenDB, + Base, + MAX_TOKEN_EXPIRY_DAYS, + create_api_token, + create_engine, + delete_api_token, + get_api_token_by_id, + hash_token_secret, + list_api_tokens, + sessionmaker, + touch_api_token, +) + + +@pytest.fixture +def db_session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'tokens.db'}") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + yield session + session.close() + engine.dispose() + + +def test_create_token_returns_plaintext_matching_stored_hash(db_session): + row, plaintext = create_api_token(db_session, "alice", "laptop", ["files:read"]) + + prefix, token_id, secret = plaintext.split("_", 2) + assert prefix == "fgt" + assert token_id == row.token_id + assert len(token_id) == 12 + assert hash_token_secret(secret) == row.token_hash + # The secret itself is never stored. + assert secret not in row.token_hash + + +def test_create_token_defaults_to_30_day_expiry(db_session): + row, _ = create_api_token(db_session, "alice", "laptop", ["files:read"]) + + expires_at = row.expires_at.replace(tzinfo=UTC) + delta = expires_at - datetime.now(UTC) + assert timedelta(days=29) < delta <= timedelta(days=30) + + +def test_create_token_rejects_expiry_above_maximum(db_session): + with pytest.raises(ValueError, match="between 1 and 365"): + create_api_token(db_session, "alice", "laptop", ["files:read"], + expires_in_days=MAX_TOKEN_EXPIRY_DAYS + 1) + + +def test_create_token_rejects_zero_day_expiry(db_session): + with pytest.raises(ValueError, match="between 1 and 365"): + create_api_token(db_session, "alice", "laptop", ["files:read"], + expires_in_days=0) + + +def test_scopes_are_stored_space_separated_and_sorted(db_session): + row, _ = create_api_token(db_session, "alice", "laptop", + ["links:write", "files:read"]) + assert row.scopes == "files:read links:write" + + +def test_token_ids_are_unique_across_creations(db_session): + ids = {create_api_token(db_session, "alice", f"t{i}", ["files:read"])[0].token_id + for i in range(50)} + assert len(ids) == 50 + + +def test_get_by_id_returns_none_for_unknown_id(db_session): + assert get_api_token_by_id(db_session, "doesnotexist") is None + + +def test_list_returns_only_the_callers_tokens(db_session): + create_api_token(db_session, "alice", "a", ["files:read"]) + create_api_token(db_session, "bob", "b", ["files:read"]) + + names = [t.name for t in list_api_tokens(db_session, "alice")] + assert names == ["a"] + + +def test_delete_removes_the_row_and_returns_one(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + + assert delete_api_token(db_session, "alice", row.token_id) == 1 + assert get_api_token_by_id(db_session, row.token_id) is None + + +def test_delete_refuses_another_users_token(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + + assert delete_api_token(db_session, "bob", row.token_id) == 0 + assert get_api_token_by_id(db_session, row.token_id) is not None + + +def test_touch_sets_last_used_when_never_used(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + assert row.last_used_at is None + + touch_api_token(db_session, row.token_id) + + assert get_api_token_by_id(db_session, row.token_id).last_used_at is not None + + +def test_touch_is_a_noop_when_recently_used(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + touch_api_token(db_session, row.token_id) + first = get_api_token_by_id(db_session, row.token_id).last_used_at + + touch_api_token(db_session, row.token_id) + + assert get_api_token_by_id(db_session, row.token_id).last_used_at == first + + +def test_touch_updates_when_stale(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + row.last_used_at = datetime.now(UTC) - timedelta(hours=1) + db_session.commit() + stale = row.last_used_at + + touch_api_token(db_session, row.token_id) + + assert get_api_token_by_id(db_session, row.token_id).last_used_at > stale + + +def test_touch_ignores_unknown_token(db_session): + touch_api_token(db_session, "doesnotexist") # must not raise +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_api_tokens.py -v` +Expected: FAIL with `ImportError: cannot import name 'ApiTokenDB' from 'fileglancer.database'` + +- [ ] **Step 3: Add the model** + +In `fileglancer/database.py`, immediately after the `SessionDB` class (which ends at line 273): + +```python +# --- API token constants --- + +API_TOKEN_PREFIX = "fgt" +API_TOKEN_ID_LENGTH = 12 +DEFAULT_TOKEN_EXPIRY_DAYS = 30 +MAX_TOKEN_EXPIRY_DAYS = 365 + +# last_used_at is refreshed at most this often, so token auth does not cost a +# database write on every single API request. +TOKEN_TOUCH_INTERVAL_SECONDS = 300 + + +class ApiTokenDB(Base): + """Database model for programmatic API tokens. + + Only the SHA-256 hash of the secret half is stored. The full token string + is returned exactly once, at creation, and is not recoverable afterwards. + """ + __tablename__ = 'api_tokens' + + id = Column(Integer, primary_key=True, autoincrement=True) + token_id = Column(String, nullable=False, unique=True, index=True) + token_hash = Column(String, nullable=False) + username = Column(String, nullable=False, index=True) + name = Column(String, nullable=False) + scopes = Column(String, nullable=False) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + expires_at = Column(DateTime, nullable=False) + last_used_at = Column(DateTime, nullable=True) +``` + +- [ ] **Step 4: Add the CRUD functions** + +In `fileglancer/database.py`, after `delete_expired_sessions` (ends line 989): + +```python +# --- API token functions --- + +def hash_token_secret(secret: str) -> str: + """Hash the secret half of an API token. + + ponytail: SHA-256 rather than a slow KDF. The secret is 32 bytes from + secrets.token_urlsafe, not a human-chosen password, so key stretching + defends against nothing here. Revisit only if user-chosen secrets are + ever allowed. + """ + return hashlib.sha256(secret.encode('utf-8')).hexdigest() + + +def create_api_token(session: Session, username: str, name: str, + scopes: List[str], + expires_in_days: int = DEFAULT_TOKEN_EXPIRY_DAYS + ) -> Tuple[ApiTokenDB, str]: + """Create an API token for a user. + + Returns (row, plaintext_token). The plaintext token is the only time the + secret is available; only its hash is persisted. + """ + if not 1 <= expires_in_days <= MAX_TOKEN_EXPIRY_DAYS: + raise ValueError( + f"expires_in_days must be between 1 and {MAX_TOKEN_EXPIRY_DAYS}") + + token_id = secrets.token_urlsafe(16)[:API_TOKEN_ID_LENGTH] + secret = secrets.token_urlsafe(32) + now = datetime.now(UTC) + + row = ApiTokenDB( + token_id=token_id, + token_hash=hash_token_secret(secret), + username=username, + name=name, + scopes=" ".join(sorted(scopes)), + created_at=now, + expires_at=now + timedelta(days=expires_in_days), + last_used_at=None, + ) + session.add(row) + session.commit() + return row, f"{API_TOKEN_PREFIX}_{token_id}_{secret}" + + +def get_api_token_by_id(session: Session, token_id: str) -> Optional[ApiTokenDB]: + """Get an API token row by its public token_id.""" + return session.query(ApiTokenDB).filter_by(token_id=token_id).first() + + +def list_api_tokens(session: Session, username: str) -> List[ApiTokenDB]: + """List a user's API tokens, newest first.""" + return (session.query(ApiTokenDB) + .filter_by(username=username) + .order_by(ApiTokenDB.created_at.desc()) + .all()) + + +def delete_api_token(session: Session, username: str, token_id: str) -> int: + """Revoke a token. Returns the number of rows deleted (0 or 1). + + Filtering on username as well as token_id means one user cannot revoke + another user's token by guessing its id. + """ + deleted = (session.query(ApiTokenDB) + .filter_by(username=username, token_id=token_id) + .delete()) + session.commit() + return deleted + + +def touch_api_token(session: Session, token_id: str) -> None: + """Refresh last_used_at, at most once per TOKEN_TOUCH_INTERVAL_SECONDS.""" + row = get_api_token_by_id(session, token_id) + if row is None: + return + + now = datetime.now(UTC) + if row.last_used_at is not None: + # SQLAlchemy strips tzinfo on read; add it back before comparing. + last_used = row.last_used_at.replace(tzinfo=UTC) + if (now - last_used).total_seconds() < TOKEN_TOUCH_INTERVAL_SECONDS: + return + + row.last_used_at = now + session.commit() +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_api_tokens.py -v` +Expected: PASS, 14 tests + +- [ ] **Step 6: Write the migration** + +Create `fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py`: + +```python +"""add api_tokens table + +Revision ID: f2a8c1d94e60 +Revises: e7b2a9c4f130 +Create Date: 2026-08-24 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f2a8c1d94e60' +down_revision = 'e7b2a9c4f130' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'api_tokens', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('token_id', sa.String(), nullable=False), + sa.Column('token_hash', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('scopes', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_api_tokens_token_id', 'api_tokens', ['token_id'], unique=True) + op.create_index('ix_api_tokens_username', 'api_tokens', ['username']) + + +def downgrade() -> None: + op.drop_index('ix_api_tokens_username', table_name='api_tokens') + op.drop_index('ix_api_tokens_token_id', table_name='api_tokens') + op.drop_table('api_tokens') +``` + +- [ ] **Step 7: Verify the migration applies cleanly** + +```bash +FILEGLANCER_MIGRATION_DB_URL=sqlite:////tmp/fgmig.db pixi run migrate +``` + +Expected: no error, and `f2a8c1d94e60` is the new head. Confirm the table exists: + +```bash +sqlite3 /tmp/fgmig.db ".schema api_tokens" && rm /tmp/fgmig.db +``` + +Expected: the `CREATE TABLE api_tokens` statement plus both indexes. + +- [ ] **Step 8: Commit** + +```bash +git add fileglancer/database.py fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py tests/test_api_tokens.py +git commit -m "feat: add api_tokens table and CRUD functions" +``` + +--- + +### Task 2: Scope table and scope-check logic + +**Files:** +- Modify: `fileglancer/auth.py` (add at the end of the file) +- Test: `tests/test_api_scopes.py` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: + - `auth.API_SCOPES: frozenset[str]` — the six valid scope names + - `auth.ANY_SCOPE: str` — the sentinel `""`, meaning "any valid token" + - `auth.required_scope(path: str, method: str) -> Optional[str]` — returns a scope name, `ANY_SCOPE`, or `None` when the path is not token-reachable + - `auth.token_has_scope(granted: str, required: str) -> bool` — `granted` is the space-separated string stored on the token row + +These two functions are pure. Keeping them free of database and request objects is what makes them cheap to test exhaustively. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_api_scopes.py`: + +```python +"""Tests for the API token scope table and scope matching. + +Both functions under test are pure, so these are plain table-driven tests +with no database or app fixture. +""" +import pytest + +from fileglancer.auth import ( + ANY_SCOPE, + API_SCOPES, + required_scope, + token_has_scope, +) + + +def test_api_scopes_are_exactly_the_six_documented_names(): + assert API_SCOPES == frozenset({ + "files:read", "files:write", + "links:read", "links:write", + "jobs:read", "jobs:write", + }) + + +@pytest.mark.parametrize("path,method,expected", [ + ("/api/files/tempdir", "GET", "files:read"), + ("/api/files/tempdir", "POST", "files:write"), + ("/api/files/tempdir", "PATCH", "files:write"), + ("/api/files/tempdir", "DELETE", "files:write"), + ("/api/content/tempdir/a.txt", "GET", "files:read"), + ("/api/content/tempdir/a.txt", "HEAD", "files:read"), + ("/api/content/tempdir/a.txt", "PUT", "files:write"), + ("/api/proxied-path", "GET", "links:read"), + ("/api/proxied-path", "POST", "links:write"), + ("/api/proxied-path/abc123", "DELETE", "links:write"), + ("/api/neuroglancer/nglinks", "GET", "links:read"), + ("/api/neuroglancer/nglinks", "POST", "links:write"), + ("/api/jobs", "GET", "jobs:read"), + ("/api/jobs", "POST", "jobs:write"), + ("/api/jobs/12/cancel", "POST", "jobs:write"), + ("/api/cluster-defaults", "GET", "jobs:read"), +]) +def test_required_scope_maps_path_and_method(path, method, expected): + assert required_scope(path, method) == expected + + +@pytest.mark.parametrize("path", ["/api/profile", "/api/auth/status"]) +def test_paths_readable_by_any_valid_token(path): + assert required_scope(path, "GET") == ANY_SCOPE + + +@pytest.mark.parametrize("path", [ + "/api/ssh-keys", + "/api/ssh-keys/generate-temp", + "/api/tokens", + "/api/tokens/abc123", + "/api/apps", + "/api/catalog", + "/api/preference/foo", + "/api/ticket", +]) +def test_unlisted_paths_are_not_token_reachable(path): + assert required_scope(path, "GET") is None + + +def test_prefix_match_does_not_leak_across_similar_paths(): + # "/api/filesystem-admin" must not match the "/api/files" prefix. + assert required_scope("/api/filesystem-admin", "GET") is None + + +@pytest.mark.parametrize("granted,required,expected", [ + ("files:read", "files:read", True), + ("files:read", "files:write", False), + ("files:write", "files:read", True), # write implies read + ("files:write", "files:write", True), + ("files:read links:write", "links:read", True), + ("files:read", "links:read", False), + ("files:write", "jobs:read", False), + ("", "files:read", False), +]) +def test_token_has_scope(granted, required, expected): + assert token_has_scope(granted, required) is expected + + +@pytest.mark.parametrize("granted", ["", "files:read", "jobs:write"]) +def test_any_scope_is_satisfied_by_any_token(granted): + assert token_has_scope(granted, ANY_SCOPE) is True +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_api_scopes.py -v` +Expected: FAIL with `ImportError: cannot import name 'ANY_SCOPE' from 'fileglancer.auth'` + +- [ ] **Step 3: Implement the scope table** + +Append to `fileglancer/auth.py`: + +```python +# --- API token scopes --- + +API_SCOPES = frozenset({ + "files:read", "files:write", + "links:read", "links:write", + "jobs:read", "jobs:write", +}) + +# Sentinel meaning "any valid token may call this", as distinct from None, +# which means "no token may call this at all". +ANY_SCOPE = "" + +# Maps a request path prefix to the resource whose scope guards it. Order does +# not matter; prefixes are mutually exclusive. +# +# Deliberately absent: /api/file-share-paths, /api/external-buckets and +# /api/version have no get_current_user dependency and are already +# unauthenticated, so this table is never consulted for them. That is what +# lets the Python client resolve paths regardless of a token's scopes. +_SCOPE_PREFIXES = ( + ("/api/files", "files"), + ("/api/content", "files"), + ("/api/proxied-path", "links"), + ("/api/neuroglancer", "links"), + ("/api/jobs", "jobs"), + ("/api/cluster-defaults", "jobs"), +) + +# Readable by any valid token regardless of scope: identity and liveness. +_ANY_SCOPE_PATHS = ("/api/profile", "/api/auth/status") + +_READ_METHODS = ("GET", "HEAD") + + +def _path_matches(path: str, prefix: str) -> bool: + """True when path is the prefix itself or a child of it. + + The explicit '/' check is what stops '/api/filesystem-admin' from being + treated as a child of '/api/files'. + """ + return path == prefix or path.startswith(prefix + "/") + + +def required_scope(path: str, method: str) -> Optional[str]: + """Return the scope an API token needs to call path with method. + + Returns ANY_SCOPE when any valid token suffices, and None when the path is + not reachable with a token at all. None is the default: a path that is not + listed here is session-only, which is what keeps /api/ssh-keys, + /api/tokens, /api/apps, /api/catalog, /api/preference and /api/ticket out + of reach without naming any of them. + """ + if any(_path_matches(path, p) for p in _ANY_SCOPE_PATHS): + return ANY_SCOPE + + for prefix, resource in _SCOPE_PREFIXES: + if _path_matches(path, prefix): + action = "read" if method.upper() in _READ_METHODS else "write" + return f"{resource}:{action}" + + return None + + +def token_has_scope(granted: str, required: str) -> bool: + """Check a token's space-separated scope string against a requirement.""" + if required == ANY_SCOPE: + return True + + held = set(granted.split()) + if required in held: + return True + + # ':write' implies ':read' for the same resource. + resource, _, action = required.partition(":") + return action == "read" and f"{resource}:write" in held +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_api_scopes.py -v` +Expected: PASS, 40 tests + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/auth.py tests/test_api_scopes.py +git commit -m "feat: add API token scope table and matching logic" +``` + +--- + +### Task 3: Bearer token authentication + +**Files:** +- Modify: `fileglancer/auth.py` (add `parse_bearer_token` and `get_user_from_token`; edit `get_current_user` at line 170) +- Modify: `fileglancer/server.py:173-186` (`get_current_user` dependency) +- Test: `tests/test_api_token_auth.py` + +**Interfaces:** +- Consumes: `db.get_api_token_by_id`, `db.hash_token_secret`, `db.touch_api_token`, `db.API_TOKEN_PREFIX` (Task 1); `auth.required_scope`, `auth.token_has_scope`, `auth.ANY_SCOPE` (Task 2). +- Produces: + - `auth.parse_bearer_token(request) -> Optional[str]` — the raw token string, or `None` when the request is not bearer-authenticated + - `auth.get_user_from_token(request, settings, raw_token: str) -> str` + - `auth.get_current_user` now handles both bearer and cookie auth + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_api_token_auth.py`: + +```python +"""End-to-end tests for bearer-token authentication and scope enforcement. + +Uses the real get_current_user dependency (no override), so these exercise the +full auth path the way a Python client would hit it. +""" +import os +import shutil +import tempfile +from datetime import datetime, timedelta, UTC + +import pytest +from fastapi.testclient import TestClient + +import fileglancer.database +import fileglancer.settings +from fileglancer.database import ( + Base, + FileSharePathDB, + create_api_token, + create_engine, + dispose_engine, + sessionmaker, +) +from fileglancer.server import create_app +from fileglancer.settings import Settings + + +@pytest.fixture +def token_app(): + """App backed by a temp database with one file share, no auth override.""" + temp_dir = tempfile.mkdtemp() + db_url = f"sqlite:///{os.path.join(temp_dir, 'test.db')}" + engine = create_engine(db_url) + Base.metadata.create_all(engine) + db_session = sessionmaker(bind=engine)() + db_session.add(FileSharePathDB( + name="tempdir", zone="testzone", group="testgroup", storage="local", + mount_path=temp_dir, mac_path="smb://tempdir/test/path", + windows_path="\\\\tempdir\\test\\path", linux_path="/tempdir/test/path", + )) + db_session.commit() + + settings = Settings(db_url=db_url, file_share_mounts=[], cli_mode=True) + original = fileglancer.settings.get_settings + fileglancer.settings.get_settings = lambda: settings + fileglancer.database.get_settings = lambda: settings + + yield create_app(settings), db_session + + db_session.close() + engine.dispose() + dispose_engine(db_url) + from fileglancer.user_worker import _filestore_cache, _user_groups_cache + _filestore_cache.clear() + _user_groups_cache.clear() + fileglancer.settings.get_settings = original + fileglancer.database.get_settings = original + shutil.rmtree(temp_dir) + + +def _client_with_token(token_app, scopes, expires_in_days=30): + app, db_session = token_app + row, plaintext = create_api_token(db_session, "alice", "test", scopes, + expires_in_days=expires_in_days) + client = TestClient(app) + client.headers["Authorization"] = f"Bearer {plaintext}" + return client, row + + +def test_valid_token_authenticates_a_scoped_request(token_app): + client, _ = _client_with_token(token_app, ["files:read"]) + + response = client.get("/api/files/tempdir") + + assert response.status_code == 200 + + +def test_write_scope_satisfies_a_read_request(token_app): + client, _ = _client_with_token(token_app, ["files:write"]) + + assert client.get("/api/files/tempdir").status_code == 200 + + +def test_read_scope_is_refused_a_write_request(token_app): + client, _ = _client_with_token(token_app, ["files:read"]) + + response = client.post("/api/files/tempdir?subpath=newdir", + json={"type": "directory"}) + + assert response.status_code == 403 + assert "files:write" in response.json()["detail"] + + +def test_wrong_resource_scope_is_refused(token_app): + client, _ = _client_with_token(token_app, ["links:read"]) + + response = client.get("/api/files/tempdir") + + assert response.status_code == 403 + + +def test_session_only_path_is_refused_to_every_token(token_app): + client, _ = _client_with_token(token_app, ["files:write", "links:write", + "jobs:write"]) + + response = client.get("/api/ssh-keys") + + assert response.status_code == 403 + assert "not accessible with an API token" in response.json()["detail"] + + +def test_a_token_cannot_mint_another_token(token_app): + client, _ = _client_with_token(token_app, ["files:write", "links:write", + "jobs:write"]) + + response = client.post("/api/tokens", json={ + "name": "escalated", "scopes": ["files:write"], "expires_in_days": 30, + }) + + assert response.status_code == 403 + + +def test_profile_is_readable_with_any_scope(token_app): + client, _ = _client_with_token(token_app, ["links:read"]) + + assert client.get("/api/profile").status_code == 200 + + +def test_expired_token_is_rejected(token_app): + app, db_session = token_app + row, plaintext = create_api_token(db_session, "alice", "old", ["files:read"]) + row.expires_at = datetime.now(UTC) - timedelta(days=1) + db_session.commit() + + client = TestClient(app) + response = client.get("/api/files/tempdir", + headers={"Authorization": f"Bearer {plaintext}"}) + + assert response.status_code == 401 + assert "expired" in response.json()["detail"].lower() + + +def test_unknown_token_id_is_rejected(token_app): + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer fgt_notarealid_secret"}) + + assert response.status_code == 401 + + +def test_wrong_secret_for_a_real_token_id_is_rejected(token_app): + app, db_session = token_app + row, _ = create_api_token(db_session, "alice", "test", ["files:read"]) + client = TestClient(app) + + response = client.get( + "/api/files/tempdir", + headers={"Authorization": f"Bearer fgt_{row.token_id}_wrongsecret"}) + + assert response.status_code == 401 + + +def test_malformed_token_is_rejected(token_app): + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer fgt_missingsecret"}) + + assert response.status_code == 401 + assert "malformed" in response.json()["detail"].lower() + + +def test_non_fgt_bearer_falls_through_to_cookie_auth(token_app): + # A bearer value that is not an fgt_ token is not a Fileglancer API token, + # so the request is treated as unauthenticated rather than rejected as a + # bad token. + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer someothertoken"}) + + assert response.status_code == 401 + assert "log in" in response.json()["detail"].lower() + + +def test_token_auth_ignores_a_disallowed_origin(token_app): + # Origin enforcement exists to protect ambient cookie auth. A bearer token + # is not ambient, so a cross-origin script with a valid token is fine. + client, _ = _client_with_token(token_app, ["files:read"]) + + response = client.get("/api/files/tempdir", + headers={"Origin": "https://evil.example.com"}) + + assert response.status_code == 200 + + +def test_token_use_updates_last_used_at(token_app): + client, row = _client_with_token(token_app, ["files:read"]) + assert row.last_used_at is None + + client.get("/api/files/tempdir") + + _, db_session = token_app + db_session.refresh(row) + assert row.last_used_at is not None + + +def test_every_authenticated_route_is_classified(token_app): + """Guard against drift: a new /api route must be deliberately placed. + + Every route that depends on get_current_user must either be reachable via + the scope table or sit under a known session-only prefix. Adding a route + without updating one of the two lists fails here. + """ + from fileglancer.auth import required_scope + from fileglancer.server import get_current_user + + SESSION_ONLY_PREFIXES = ( + "/api/ticket", + "/api/preference", + "/api/ssh-keys", + "/api/apps", + "/api/catalog", + "/api/tokens", + ) + + app, _ = token_app + unclassified = [] + for route in app.routes: + path = getattr(route, "path", "") + dependant = getattr(route, "dependant", None) + if not path.startswith("/api/") or dependant is None: + continue + if not any(d.call is get_current_user for d in dependant.dependencies): + continue + if path.startswith(SESSION_ONLY_PREFIXES): + continue + for method in getattr(route, "methods", set()): + if required_scope(path, method) is None: + unclassified.append(f"{method} {path}") + + assert not unclassified, ( + "These authenticated routes are neither scoped nor session-only. Add " + "them to _SCOPE_PREFIXES in auth.py or to SESSION_ONLY_PREFIXES here: " + f"{sorted(unclassified)}" + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_api_token_auth.py -v` +Expected: FAIL — every token test returns 401 because bearer auth does not exist yet. + +- [ ] **Step 3: Add the token resolution functions** + +In `fileglancer/auth.py`, add `import hmac` to the imports at the top, then append after `token_has_scope`: + +```python +def parse_bearer_token(request: Request) -> Optional[str]: + """Extract a Fileglancer API token from the Authorization header. + + Returns None when the header is absent, is not a Bearer credential, or + carries a Bearer value that is not one of our tokens. In all three cases + the caller falls through to cookie auth rather than failing, so an + unrelated Bearer header does not break a cookie-authenticated request. + """ + header = request.headers.get('authorization', '') + scheme, _, value = header.partition(' ') + value = value.strip() + if scheme.lower() != 'bearer' or not value.startswith(db.API_TOKEN_PREFIX + '_'): + return None + return value + + +def get_user_from_token(request: Request, settings: Settings, raw_token: str) -> str: + """Resolve an API token to a username, enforcing expiry and scope. + + Raises HTTPException(401) for an invalid or expired token and + HTTPException(403) when the token lacks the scope the request needs. + """ + parts = raw_token.split('_', 2) + if len(parts) != 3 or not parts[1] or not parts[2]: + raise HTTPException(status_code=401, detail="Malformed API token") + _, token_id, secret = parts + + with db.get_db_session(settings.db_url) as session: + row = db.get_api_token_by_id(session, token_id) + # Hash the presented secret regardless, then compare in constant time. + presented = db.hash_token_secret(secret) + if row is None or not hmac.compare_digest(presented, row.token_hash): + logger.info(f"Rejected API token with id {token_id}") + raise HTTPException(status_code=401, detail="Invalid API token") + + # SQLAlchemy strips tzinfo on read; add it back before comparing. + expires_at = row.expires_at.replace(tzinfo=UTC) + if expires_at < datetime.now(UTC): + raise HTTPException( + status_code=401, + detail=f"API token expired on {expires_at.date().isoformat()}") + + username = row.username + granted = row.scopes + + required = required_scope(request.url.path, request.method) + if required is None: + raise HTTPException( + status_code=403, + detail=f"{request.url.path} is not accessible with an API token") + if not token_has_scope(granted, required): + raise HTTPException( + status_code=403, + detail=f"API token is missing the required scope: {required}") + + with db.get_db_session(settings.db_url) as session: + db.touch_api_token(session, token_id) + + return username +``` + +- [ ] **Step 4: Wire it into get_current_user** + +Replace the body of `auth.get_current_user` (currently at `fileglancer/auth.py:170-186`): + +```python +def get_current_user(request: Request, settings: Settings) -> str: + """ + Get the current authenticated user + + Resolves an Authorization: Bearer API token when one is present, and + otherwise validates the session cookie (for both OKTA and simple auth). + Because every authenticated route depends on this one function, adding + token auth here covers the whole API without touching any route. + + Raises HTTPException(401) if authentication fails + """ + raw_token = parse_bearer_token(request) + if raw_token: + return get_user_from_token(request, settings, raw_token) + + user_session = get_session_from_cookie(request, settings) + + if not user_session: + raise HTTPException( + status_code=401, + detail="Authentication required. Please log in." + ) + + return user_session.username +``` + +- [ ] **Step 5: Skip origin enforcement for bearer auth** + +Replace `fileglancer/server.py:173-186`: + +```python +def get_current_user(request: Request): + """ + FastAPI dependency to get the current authenticated user + + Resolves either an Authorization: Bearer API token or a session cookie. + + The cross-origin allowlist is enforced for cookie auth only. Origin + enforcement exists because the session cookie is ambient — a browser + attaches it to same-site requests whether or not the page meant to make + them. A bearer token is never ambient, so there is no CSRF surface to + defend, and programmatic clients send no Origin header at all. + """ + settings = get_settings() + if auth.parse_bearer_token(request) is None: + auth.enforce_request_origin(request, settings) + return auth.get_current_user(request, settings) +``` + +- [ ] **Step 6: Run the new tests** + +Run: `pixi run -e test test-backend -- tests/test_api_token_auth.py -v` +Expected: PASS for all except `test_a_token_cannot_mint_another_token`, which fails with 404 because `/api/tokens` does not exist yet. Mark it with `@pytest.mark.xfail(reason="POST /api/tokens lands in Task 4", strict=True)` and remove the marker in Task 4. + +- [ ] **Step 7: Run the full backend suite to confirm nothing regressed** + +Run: `pixi run -e test test-backend` +Expected: PASS. Cookie auth is untouched, so `tests/test_api_auth.py` and `tests/test_endpoints.py` must still be green. + +- [ ] **Step 8: Commit** + +```bash +git add fileglancer/auth.py fileglancer/server.py tests/test_api_token_auth.py +git commit -m "feat: authenticate API requests with scoped bearer tokens" +``` + +--- + +### Task 4: Token management endpoints + +**Files:** +- Modify: `fileglancer/model.py` (add models near the end, before the App Manifest section at line 321) +- Modify: `fileglancer/server.py` (add `_convert_api_token` near `_convert_ticket` at line 257; add routes after the SSH key routes, around line 1447) +- Test: `tests/test_api_token_endpoints.py` + +**Interfaces:** +- Consumes: `db.create_api_token`, `db.list_api_tokens`, `db.delete_api_token`, `db.ApiTokenDB` (Task 1); `auth.API_SCOPES` (Task 2). +- Produces: + - `model.ApiTokenInfo`, `model.ApiTokenListResponse`, `model.ApiTokenCreateRequest`, `model.ApiTokenCreateResponse` + - `GET /api/tokens`, `POST /api/tokens`, `DELETE /api/tokens/{token_id}` + - Response shape for `POST`: `{"token": {...ApiTokenInfo}, "secret": "fgt_..."}` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_api_token_endpoints.py`: + +```python +"""Tests for the /api/tokens management endpoints. + +These use the cookie-auth override from test_endpoints, since token +management is deliberately session-only. +""" +import pytest + +pytest_plugins = [] + +from test_endpoints import TEST_USERNAME, temp_dir, test_app, test_client # noqa: F401 + + +def test_list_is_empty_for_a_new_user(test_client): + response = test_client.get("/api/tokens") + + assert response.status_code == 200 + assert response.json() == {"tokens": []} + + +def test_create_returns_the_secret_exactly_once(test_client): + response = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], "expires_in_days": 30, + }) + + assert response.status_code == 201 + body = response.json() + assert body["secret"].startswith("fgt_") + assert body["token"]["name"] == "laptop" + assert body["token"]["scopes"] == ["files:read"] + + # The secret never appears again in the listing. + listing = test_client.get("/api/tokens").json() + assert len(listing["tokens"]) == 1 + assert "secret" not in listing["tokens"][0] + assert "token_hash" not in listing["tokens"][0] + + +def test_create_defaults_to_thirty_days(test_client): + response = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], + }) + + assert response.status_code == 201 + + +def test_create_rejects_unknown_scope(test_client): + response = test_client.post("/api/tokens", json={ + "name": "bad", "scopes": ["files:read", "secrets:steal"], + }) + + assert response.status_code == 400 + assert "secrets:steal" in response.json()["detail"] + + +def test_create_rejects_empty_scope_list(test_client): + response = test_client.post("/api/tokens", json={"name": "bad", "scopes": []}) + + assert response.status_code == 400 + + +def test_create_rejects_expiry_over_the_maximum(test_client): + response = test_client.post("/api/tokens", json={ + "name": "forever", "scopes": ["files:read"], "expires_in_days": 366, + }) + + assert response.status_code == 422 + + +def test_create_rejects_blank_name(test_client): + response = test_client.post("/api/tokens", json={ + "name": "", "scopes": ["files:read"], + }) + + assert response.status_code == 422 + + +def test_delete_revokes_the_token(test_client): + token_id = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], + }).json()["token"]["token_id"] + + assert test_client.delete(f"/api/tokens/{token_id}").status_code == 200 + assert test_client.get("/api/tokens").json() == {"tokens": []} + + +def test_delete_unknown_token_returns_404(test_client): + assert test_client.delete("/api/tokens/doesnotexist").status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_api_token_endpoints.py -v` +Expected: FAIL with 404 on every request — the routes do not exist. + +- [ ] **Step 3: Add the Pydantic models** + +In `fileglancer/model.py`, before the `# --- App Manifest Models ---` comment at line 321: + +```python +# --- API token models --- + +class ApiTokenInfo(BaseModel): + """An API token, without its secret""" + token_id: str = Field( + description="Public identifier for the token, used to revoke it" + ) + name: str = Field( + description="User-supplied label for the token" + ) + scopes: List[str] = Field( + description="Scopes granted to this token" + ) + created_at: datetime = Field( + description="When this token was created" + ) + expires_at: datetime = Field( + description="When this token stops working" + ) + last_used_at: Optional[datetime] = Field( + description="When this token was last used, accurate to five minutes", + default=None + ) + + +class ApiTokenListResponse(BaseModel): + tokens: List[ApiTokenInfo] = Field( + description="The caller's API tokens, newest first" + ) + + +class ApiTokenCreateRequest(BaseModel): + """Request payload for creating an API token""" + name: str = Field( + description="A label to identify this token later", + min_length=1, max_length=100 + ) + scopes: List[str] = Field( + description="Scopes to grant, e.g. ['files:read', 'links:write']" + ) + expires_in_days: int = Field( + description="How long the token stays valid", + default=30, ge=1, le=365 + ) + + +class ApiTokenCreateResponse(BaseModel): + """Response for a newly created API token""" + token: ApiTokenInfo = Field( + description="The token metadata" + ) + secret: str = Field( + description="The full token string. Returned once and never recoverable." + ) +``` + +- [ ] **Step 4: Add the converter and routes** + +In `fileglancer/server.py`, add after `_convert_ticket` (ends around line 266): + +```python +def _convert_api_token(row: db.ApiTokenDB) -> ApiTokenInfo: + """Convert an ApiTokenDB row to the public ApiTokenInfo model. + + Deliberately does not carry token_hash: this model is what the listing + endpoint returns. + """ + return ApiTokenInfo( + token_id=row.token_id, + name=row.name, + scopes=row.scopes.split(), + created_at=row.created_at, + expires_at=row.expires_at, + last_used_at=row.last_used_at, + ) +``` + +Add the new names to the `fileglancer.model` import list at the top of `server.py`: `ApiTokenInfo`, `ApiTokenListResponse`, `ApiTokenCreateRequest`, `ApiTokenCreateResponse`. + +Then add the routes inside `create_app`, immediately after the `generate_temp_ssh_key` route (ends around line 1447): + +```python + # API token management. These endpoints are session-only: they are absent + # from the scope table in auth.py, so deny-by-default means a token can + # never be used to mint or revoke another token. + @app.get("/api/tokens", response_model=ApiTokenListResponse, + description="List the current user's API tokens") + async def list_api_tokens(username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + rows = db.list_api_tokens(session, username) + return ApiTokenListResponse( + tokens=[_convert_api_token(row) for row in rows]) + + + @app.post("/api/tokens", response_model=ApiTokenCreateResponse, + status_code=201, + description="Create an API token; the secret is returned once") + async def create_api_token(payload: ApiTokenCreateRequest, + username: str = Depends(get_current_user)): + if not payload.scopes: + raise HTTPException(status_code=400, + detail="At least one scope is required") + unknown = sorted(set(payload.scopes) - auth.API_SCOPES) + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown scopes: {', '.join(unknown)}. " + f"Valid scopes: {', '.join(sorted(auth.API_SCOPES))}") + + with db.get_db_session(settings.db_url) as session: + row, secret = db.create_api_token( + session, username, payload.name.strip(), payload.scopes, + expires_in_days=payload.expires_in_days) + logger.info(f"Created API token {row.token_id} for {username} " + f"with scopes {row.scopes}") + return ApiTokenCreateResponse(token=_convert_api_token(row), + secret=secret) + + + @app.delete("/api/tokens/{token_id}", description="Revoke an API token") + async def delete_api_token(token_id: str = Path(..., description="The token's public id"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + if db.delete_api_token(session, username, token_id) == 0: + raise HTTPException(status_code=404, detail="Token not found") + logger.info(f"Revoked API token {token_id} for {username}") + return {"message": f"Token {token_id} revoked"} +``` + +- [ ] **Step 5: Run the endpoint tests** + +Run: `pixi run -e test test-backend -- tests/test_api_token_endpoints.py -v` +Expected: PASS, 9 tests + +- [ ] **Step 6: Un-xfail the escalation test from Task 3** + +Remove the `@pytest.mark.xfail` marker from `test_a_token_cannot_mint_another_token` in `tests/test_api_token_auth.py`. + +Run: `pixi run -e test test-backend -- tests/test_api_token_auth.py -v` +Expected: PASS, all 15 tests including the escalation guard, which now gets a real 403 rather than a 404. + +- [ ] **Step 7: Commit** + +```bash +git add fileglancer/model.py fileglancer/server.py tests/test_api_token_endpoints.py tests/test_api_token_auth.py +git commit -m "feat: add /api/tokens management endpoints" +``` + +--- + +### Task 5: Python client construction and path resolution + +**Files:** +- Create: `fileglancer/client.py` +- Modify: `fileglancer/__init__.py` (currently empty) +- Test: `tests/test_client_paths.py` + +**Interfaces:** +- Consumes: nothing from earlier tasks; talks to `/api/file-share-paths`, which needs no auth. +- Produces: + - `fileglancer.FileglancerError(message, status_code=None)` + - `fileglancer.Fileglancer(url=None, token=None, timeout=60.0, transport=None)` + - `Fileglancer.file_share_paths() -> list[FileSharePath]` + - `Fileglancer.refresh() -> None` + - `Fileglancer._resolve(path: str) -> tuple[str, str]` returning `(fsp_name, fsp_relative_path)` + - `Fileglancer.abspath(fsp_name: str, path: str = "") -> str` + - `Fileglancer._request(method: str, path: str, **kwargs) -> httpx.Response` + - `fileglancer.client.NEUROGLANCER_URL` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_paths.py`: + +```python +"""Tests for client construction and absolute-path resolution. + +Resolution mirrors resolvePathToFsp in frontend/src/utils/pathHandling.ts. +The FSP fixture below is the shared fixture set both resolvers are checked +against; keep it in sync with the TypeScript test. +""" +import pytest + +from fileglancer.client import Fileglancer, FileglancerError +from fileglancer.model import FileSharePath + + +# Shared fixture set. The '/misc/public' vs '/misc/public-archive' pair is the +# prefix-boundary case; 'home' exercises an FSP with no alternate mount forms. +SHARED_FSPS = [ + FileSharePath(name="nearline", zone="z", mount_path="/nearline", + linux_path="/nearline", mac_path="smb://store/nearline", + windows_path="\\\\store\\nearline"), + FileSharePath(name="public", zone="z", mount_path="/misc/public", + linux_path="/misc/public"), + FileSharePath(name="public-archive", zone="z", + mount_path="/misc/public-archive", + linux_path="/misc/public-archive"), + FileSharePath(name="home", zone="z", mount_path="/home/alice"), +] + + +@pytest.fixture +def fg(): + """A client with the FSP list pre-seeded, so no HTTP call is made.""" + client = Fileglancer(url="http://testserver", token="fgt_abc_def") + client._fsp_cache = SHARED_FSPS + return client + + +def test_requires_a_url(monkeypatch): + monkeypatch.delenv("FILEGLANCER_URL", raising=False) + monkeypatch.setenv("FILEGLANCER_TOKEN", "fgt_abc_def") + + with pytest.raises(FileglancerError, match="FILEGLANCER_URL"): + Fileglancer() + + +def test_requires_a_token(monkeypatch): + monkeypatch.setenv("FILEGLANCER_URL", "http://testserver") + monkeypatch.delenv("FILEGLANCER_TOKEN", raising=False) + + with pytest.raises(FileglancerError, match="FILEGLANCER_TOKEN"): + Fileglancer() + + +def test_reads_url_and_token_from_the_environment(monkeypatch): + monkeypatch.setenv("FILEGLANCER_URL", "http://testserver/") + monkeypatch.setenv("FILEGLANCER_TOKEN", "fgt_abc_def") + + client = Fileglancer() + + assert str(client._client.base_url) == "http://testserver" + assert client._client.headers["authorization"] == "Bearer fgt_abc_def" + + +def test_explicit_arguments_win_over_the_environment(monkeypatch): + monkeypatch.setenv("FILEGLANCER_URL", "http://from-env") + monkeypatch.setenv("FILEGLANCER_TOKEN", "fgt_env_env") + + client = Fileglancer(url="http://explicit", token="fgt_x_y") + + assert str(client._client.base_url) == "http://explicit" + assert client._client.headers["authorization"] == "Bearer fgt_x_y" + + +@pytest.mark.parametrize("path,expected", [ + ("/nearline/alice/sample.zarr", ("nearline", "alice/sample.zarr")), + ("/nearline", ("nearline", "")), + ("/nearline/", ("nearline", "")), + ("/home/alice/data", ("home", "data")), + ("smb://store/nearline/alice", ("nearline", "alice")), + ("\\\\store\\nearline\\alice", ("nearline", "alice")), + (" /nearline/alice ", ("nearline", "alice")), +]) +def test_resolves_every_mount_form(fg, path, expected): + assert fg._resolve(path) == expected + + +def test_longest_prefix_wins_at_a_shared_boundary(fg): + # '/misc/public-archive/x' must not resolve to the 'public' share. + assert fg._resolve("/misc/public-archive/x") == ("public-archive", "x") + assert fg._resolve("/misc/public/x") == ("public", "x") + + +def test_partial_segment_does_not_match(fg): + # '/nearlinex' shares a string prefix with '/nearline' but is a different + # directory, so it must not resolve. + with pytest.raises(FileglancerError): + fg._resolve("/nearlinex/data") + + +def test_unmatched_path_error_lists_available_mounts(fg): + with pytest.raises(FileglancerError) as excinfo: + fg._resolve("/not/a/share/at/all") + + message = str(excinfo.value) + assert "/not/a/share/at/all" in message + assert "/nearline" in message + assert "/home/alice" in message + + +def test_abspath_round_trips_resolve(fg): + fsp_name, relative = fg._resolve("/nearline/alice/sample.zarr") + + assert fg.abspath(fsp_name, relative) == "/nearline/alice/sample.zarr" + + +def test_abspath_of_the_share_root(fg): + assert fg.abspath("nearline") == "/nearline" + + +def test_abspath_rejects_an_unknown_share(fg): + with pytest.raises(FileglancerError, match="Unknown file share"): + fg.abspath("nosuchshare", "x") + + +def test_refresh_drops_the_cache(fg): + assert fg._fsp_cache is not None + + fg.refresh() + + assert fg._fsp_cache is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_client_paths.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'fileglancer.client'` + +- [ ] **Step 3: Write the client core** + +Create `fileglancer/client.py`: + +```python +"""Python client for the Fileglancer HTTP API. + +Authenticates with an API token created in the Fileglancer web UI, and +operates on absolute filesystem paths rather than Fileglancer's internal +(file share, relative path) pair. + + from fileglancer import Fileglancer + + fg = Fileglancer() # reads FILEGLANCER_URL and FILEGLANCER_TOKEN + link = fg.create_data_link("/nearline/alice/sample.zarr") +""" +import os +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from fileglancer.model import FileSharePath + +# Neuroglancer base URL used when the caller does not supply one. The server +# has no configured default and POST /api/neuroglancer/nglinks requires +# url_base when given a state directly, so the default lives here. +NEUROGLANCER_URL = "https://neuroglancer-demo.appspot.com" + + +class FileglancerError(Exception): + """Raised for API errors and for paths that match no file share.""" + + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class Fileglancer: + """A client for the Fileglancer HTTP API. + + Args: + url: Fileglancer server URL. Defaults to $FILEGLANCER_URL. + token: An API token created in the web UI. Defaults to + $FILEGLANCER_TOKEN. + timeout: Per-request timeout in seconds. + transport: An httpx transport, for testing against an ASGI app. + """ + + def __init__(self, url: Optional[str] = None, token: Optional[str] = None, + timeout: float = 60.0, + transport: Optional[httpx.BaseTransport] = None): + url = url or os.environ.get("FILEGLANCER_URL") + token = token or os.environ.get("FILEGLANCER_TOKEN") + if not url: + raise FileglancerError( + "No Fileglancer server URL. Pass url= or set FILEGLANCER_URL.") + if not token: + raise FileglancerError( + "No API token. Pass token= or set FILEGLANCER_TOKEN. Create a " + "token on the API Tokens page of the Fileglancer web UI.") + + self._client = httpx.Client( + base_url=url.rstrip("/"), + headers={"Authorization": f"Bearer {token}"}, + timeout=timeout, + transport=transport, + follow_redirects=True, + ) + self._fsp_cache: Optional[List[FileSharePath]] = None + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._client.close() + + def __enter__(self) -> "Fileglancer": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + # --- HTTP plumbing --- + + def _request(self, method: str, path: str, **kwargs) -> httpx.Response: + """Make a request, turning any error response into FileglancerError.""" + response = self._client.request(method, path, **kwargs) + if response.status_code >= 400: + try: + detail = response.json().get("detail", response.text) + except ValueError: + detail = response.text + raise FileglancerError(f"{method} {path} failed: {detail}", + status_code=response.status_code) + return response + + # --- File shares and path resolution --- + + def file_share_paths(self) -> List[FileSharePath]: + """List the server's file shares. Cached; call refresh() to re-fetch.""" + if self._fsp_cache is None: + data = self._request("GET", "/api/file-share-paths").json() + self._fsp_cache = [FileSharePath(**p) for p in data["paths"]] + return self._fsp_cache + + def refresh(self) -> None: + """Drop the cached file share list.""" + self._fsp_cache = None + + def _resolve(self, path: str) -> Tuple[str, str]: + """Resolve an absolute path to (file share name, relative path). + + Mirrors resolvePathToFsp in frontend/src/utils/pathHandling.ts: the + longest matching prefix across every mount form wins, and the + remainder must be empty or start with '/'. That last condition is what + keeps '/misc/public' from swallowing '/misc/public-archive'. + + Accepts Linux, Mac (smb://) and Windows (UNC) mount forms, so a path + pasted from any client platform resolves. + """ + normalized = path.strip().replace("\\", "/") + + best_fsp: Optional[FileSharePath] = None + best_prefix = "" + for fsp in self.file_share_paths(): + candidates = (fsp.mount_path, fsp.linux_path, fsp.mac_path, + fsp.windows_path) + for candidate in candidates: + if not candidate: + continue + candidate = candidate.replace("\\", "/").rstrip("/") + if len(candidate) <= len(best_prefix): + continue + if not normalized.startswith(candidate): + continue + remainder = normalized[len(candidate):] + if remainder and not remainder.startswith("/"): + continue + best_fsp, best_prefix = fsp, candidate + + if best_fsp is None: + mounts = ", ".join(sorted(f.mount_path + for f in self.file_share_paths())) + raise FileglancerError( + f"No file share matches {path!r}. Available mount points: {mounts}") + + return best_fsp.name, normalized[len(best_prefix):].strip("/") + + def abspath(self, fsp_name: str, path: str = "") -> str: + """Build an absolute path from a file share name and relative path.""" + for fsp in self.file_share_paths(): + if fsp.name == fsp_name: + root = fsp.mount_path.rstrip("/") + return f"{root}/{path}" if path else root + raise FileglancerError(f"Unknown file share: {fsp_name}") +``` + +- [ ] **Step 4: Export from the package** + +Write `fileglancer/__init__.py`: + +```python +"""Fileglancer: NGFF browsing and sharing platform.""" +from fileglancer.client import NEUROGLANCER_URL, Fileglancer, FileglancerError + +__all__ = ["Fileglancer", "FileglancerError", "NEUROGLANCER_URL"] +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_client_paths.py -v` +Expected: PASS, 22 tests + +- [ ] **Step 6: Verify the package import did not break the server** + +Run: `pixi run -e test test-backend` +Expected: PASS. `fileglancer/__init__.py` was previously empty, so confirm the new imports introduce no circular import — `client.py` imports only `fileglancer.model`, never `server` or `database`. + +- [ ] **Step 7: Commit** + +```bash +git add fileglancer/client.py fileglancer/__init__.py tests/test_client_paths.py +git commit -m "feat: add Python client with absolute path resolution" +``` + +--- + +### Task 6: Python client file operations + +**Files:** +- Modify: `fileglancer/client.py` +- Test: `tests/test_client_files.py` + +**Interfaces:** +- Consumes: `Fileglancer._resolve`, `Fileglancer._request` (Task 5). +- Produces: + - `Fileglancer.ls(path: str) -> list[FileInfo]` + - `Fileglancer.stat(path: str) -> FileInfo` + - `Fileglancer.mkdir(path: str) -> None` + - `Fileglancer.rename(src: str, dst: str) -> None` + - `Fileglancer.delete(path: str) -> None` + - `Fileglancer.read(path: str) -> bytes` + - `Fileglancer.write(path: str, data: bytes) -> int` returning bytes written + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_files.py`: + +```python +"""Client file operations, driven against the real app over ASGI. + +Reuses the token_app fixture from test_api_token_auth so the client exercises +the full bearer-auth path rather than a dependency override. +""" +import os + +import httpx +import pytest + +from fileglancer.client import Fileglancer, FileglancerError +from fileglancer.database import create_api_token + +from test_api_token_auth import token_app # noqa: F401 + + +@pytest.fixture +def fg(token_app): + """A client wired to the test app via ASGI, with all scopes.""" + app, db_session = token_app + _, plaintext = create_api_token( + db_session, "alice", "test", + ["files:write", "links:write", "jobs:write"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + yield client + client.close() + + +@pytest.fixture +def share_root(token_app): + """The temp directory backing the 'tempdir' file share.""" + app, db_session = token_app + from fileglancer.database import get_file_share_paths + return get_file_share_paths(db_session)[0].mount_path + + +def test_ls_lists_the_share_root(fg, share_root): + os.makedirs(os.path.join(share_root, "adir"), exist_ok=True) + + names = [f.name for f in fg.ls(share_root)] + + assert "adir" in names + + +def test_ls_returns_absolute_paths(fg, share_root): + os.makedirs(os.path.join(share_root, "adir"), exist_ok=True) + + entry = next(f for f in fg.ls(share_root) if f.name == "adir") + + assert entry.absolute_path == os.path.join(share_root, "adir") + + +def test_stat_describes_the_path_itself(fg, share_root): + info = fg.stat(share_root) + + assert info.is_dir is True + + +def test_mkdir_creates_a_directory(fg, share_root): + target = os.path.join(share_root, "created") + + fg.mkdir(target) + + assert os.path.isdir(target) + + +def test_write_then_read_round_trips(fg, share_root): + target = os.path.join(share_root, "notes.txt") + + written = fg.write(target, b"hello world") + + assert written == 11 + assert fg.read(target) == b"hello world" + + +def test_rename_moves_within_a_share(fg, share_root): + src = os.path.join(share_root, "before.txt") + dst = os.path.join(share_root, "after.txt") + fg.write(src, b"x") + + fg.rename(src, dst) + + assert os.path.exists(dst) + assert not os.path.exists(src) + + +def test_rename_across_shares_is_refused_before_any_request(fg, share_root): + # '/tempdir/test/path' is the linux_path of the same share, so build a + # genuinely different one by pointing at a share that does not exist. + with pytest.raises(FileglancerError, match="No file share matches"): + fg.rename(os.path.join(share_root, "a.txt"), "/nowhere/b.txt") + + +def test_delete_removes_a_file(fg, share_root): + target = os.path.join(share_root, "doomed.txt") + fg.write(target, b"x") + + fg.delete(target) + + assert not os.path.exists(target) + + +def test_error_response_becomes_a_fileglancer_error(fg, share_root): + with pytest.raises(FileglancerError) as excinfo: + fg.ls(os.path.join(share_root, "does-not-exist")) + + assert excinfo.value.status_code in (403, 404) + + +def test_a_read_only_token_cannot_write(token_app, share_root): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "ro", ["files:read"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + + with pytest.raises(FileglancerError) as excinfo: + client.mkdir(os.path.join(share_root, "nope")) + + assert excinfo.value.status_code == 403 + client.close() +``` + +Add a cross-share rename test that exercises the same-share check directly, since the fixture only has one share: + +```python +def test_rename_rejects_a_cross_share_move(fg, monkeypatch): + """Two resolvable paths on different shares must be refused locally.""" + monkeypatch.setattr(fg, "_resolve", lambda p: ( + ("shareA", "a.txt") if "a.txt" in p else ("shareB", "b.txt"))) + + with pytest.raises(FileglancerError, match="same file share"): + fg.rename("/x/a.txt", "/y/b.txt") +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_client_files.py -v` +Expected: FAIL with `AttributeError: 'Fileglancer' object has no attribute 'ls'` + +- [ ] **Step 3: Implement the file operations** + +Add to `fileglancer/client.py`. Add `from fileglancer.filestore import FileInfo` to the imports first. + +```python + # --- File operations --- + + def ls(self, path: str) -> List[FileInfo]: + """List the contents of a directory. + + Each returned FileInfo carries an absolute_path, so results can be fed + straight back into any other method. + """ + fsp_name, subpath = self._resolve(path) + data = self._request("GET", f"/api/files/{fsp_name}", + params={"subpath": subpath}).json() + return [FileInfo(**entry) for entry in data.get("files", [])] + + def stat(self, path: str) -> FileInfo: + """Get metadata for a single file or directory.""" + fsp_name, subpath = self._resolve(path) + data = self._request("GET", f"/api/files/{fsp_name}", + params={"subpath": subpath}).json() + return FileInfo(**data["info"]) + + def mkdir(self, path: str) -> None: + """Create a directory. The parent directory must already exist.""" + fsp_name, subpath = self._resolve(path) + self._request("POST", f"/api/files/{fsp_name}", + params={"subpath": subpath}, json={"type": "directory"}) + + def rename(self, src: str, dst: str) -> None: + """Rename or move a file or directory within one file share.""" + src_fsp, src_subpath = self._resolve(src) + dst_fsp, dst_subpath = self._resolve(dst) + if src_fsp != dst_fsp: + # The underlying PATCH /api/files cannot move across shares, so + # fail here with a useful message rather than on a 400. + raise FileglancerError( + f"Cannot move between file shares: {src!r} is on {src_fsp!r} " + f"but {dst!r} is on {dst_fsp!r}. Both must be on the same " + f"file share.") + self._request("PATCH", f"/api/files/{src_fsp}", + params={"subpath": src_subpath}, + json={"path": dst_subpath}) + + def delete(self, path: str) -> None: + """Delete a file or an empty directory.""" + fsp_name, subpath = self._resolve(path) + self._request("DELETE", f"/api/files/{fsp_name}", + params={"subpath": subpath}) + + def read(self, path: str) -> bytes: + """Read a file's contents.""" + fsp_name, subpath = self._resolve(path) + return self._request("GET", f"/api/content/{fsp_name}", + params={"subpath": subpath}).content + + def write(self, path: str, data: bytes) -> int: + """Write bytes to a file, creating or replacing it. + + The parent directory must already exist. Returns the number of bytes + written. + """ + fsp_name, subpath = self._resolve(path) + response = self._request("PUT", f"/api/content/{fsp_name}", + params={"subpath": subpath}, content=data) + return response.json()["bytes_written"] +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_client_files.py -v` +Expected: PASS, 11 tests + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/client.py tests/test_client_files.py +git commit -m "feat: add file operations to the Python client" +``` + +--- + +### Task 7: Python client data links and Neuroglancer links + +**Files:** +- Modify: `fileglancer/client.py` +- Test: `tests/test_client_links.py` + +**Interfaces:** +- Consumes: `Fileglancer._resolve`, `Fileglancer._request`, `Fileglancer.abspath` (Task 5); `NEUROGLANCER_URL` (Task 5). +- Produces: + - `Fileglancer.create_data_link(path: str, url_prefix: Optional[str] = None) -> ProxiedPath` + - `Fileglancer.data_links() -> list[ProxiedPath]` + - `Fileglancer.data_link(sharing_key: str) -> ProxiedPath` + - `Fileglancer.delete_data_link(sharing_key: str) -> None` + - `Fileglancer.create_ng_link(state: dict, url_base: str = NEUROGLANCER_URL, title: Optional[str] = None, short_name: Optional[str] = None) -> str` + - `Fileglancer.ng_links() -> list[NeuroglancerShortLink]` + - `Fileglancer.delete_ng_link(short_key: str) -> None` + +Every returned `ProxiedPath` has its `path` field rewritten to an absolute path. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_links.py`: + +```python +"""Client data-link and Neuroglancer-link operations.""" +import os + +import httpx +import pytest + +from fileglancer.client import NEUROGLANCER_URL, Fileglancer, FileglancerError +from fileglancer.database import create_api_token, get_file_share_paths + +from test_api_token_auth import token_app # noqa: F401 + + +@pytest.fixture +def fg(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "test", + ["files:write", "links:write"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + yield client + client.close() + + +@pytest.fixture +def shared_dir(token_app): + """A real directory inside the test share, ready to be linked.""" + _, db_session = token_app + root = get_file_share_paths(db_session)[0].mount_path + path = os.path.join(root, "sample.zarr") + os.makedirs(path, exist_ok=True) + return path + + +def test_create_data_link_from_an_absolute_path(fg, shared_dir): + link = fg.create_data_link(shared_dir) + + assert link.fsp_name == "tempdir" + assert link.sharing_key + assert str(link.url).endswith("/sample.zarr") + + +def test_created_data_link_reports_an_absolute_path(fg, shared_dir): + link = fg.create_data_link(shared_dir) + + assert link.path == shared_dir + + +def test_create_data_link_accepts_a_url_prefix(fg, shared_dir): + link = fg.create_data_link(shared_dir, url_prefix="custom") + + assert str(link.url).endswith("/custom") + + +def test_create_data_link_rejects_an_unresolvable_path(fg): + with pytest.raises(FileglancerError, match="No file share matches"): + fg.create_data_link("/nowhere/at/all") + + +def test_list_data_links_reports_absolute_paths(fg, shared_dir): + fg.create_data_link(shared_dir) + + links = fg.data_links() + + assert [link.path for link in links] == [shared_dir] + + +def test_get_data_link_by_sharing_key(fg, shared_dir): + created = fg.create_data_link(shared_dir) + + fetched = fg.data_link(created.sharing_key) + + assert fetched.sharing_key == created.sharing_key + assert fetched.path == shared_dir + + +def test_delete_data_link(fg, shared_dir): + created = fg.create_data_link(shared_dir) + + fg.delete_data_link(created.sharing_key) + + assert fg.data_links() == [] + + +def test_create_ng_link_returns_a_neuroglancer_url(fg): + url = fg.create_ng_link({"layers": []}, title="sample") + + assert url.startswith(NEUROGLANCER_URL + "#!") + assert "/ng/" in url + + +def test_create_ng_link_honours_a_custom_url_base(fg): + url = fg.create_ng_link({"layers": []}, url_base="https://ng.example.com") + + assert url.startswith("https://ng.example.com#!") + + +def test_create_ng_link_with_a_short_name(fg): + url = fg.create_ng_link({"layers": []}, short_name="my-view") + + assert url.endswith("/my-view") + + +def test_list_ng_links(fg): + fg.create_ng_link({"layers": []}, title="sample") + + links = fg.ng_links() + + assert len(links) == 1 + + +def test_delete_ng_link(fg): + fg.create_ng_link({"layers": []}, short_name="doomed") + short_key = fg.ng_links()[0].short_key + + fg.delete_ng_link(short_key) + + assert fg.ng_links() == [] + + +def test_a_files_only_token_cannot_create_a_data_link(token_app, shared_dir): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "files-only", + ["files:write"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + + with pytest.raises(FileglancerError) as excinfo: + client.create_data_link(shared_dir) + + assert excinfo.value.status_code == 403 + client.close() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_client_links.py -v` +Expected: FAIL with `AttributeError: 'Fileglancer' object has no attribute 'create_data_link'` + +- [ ] **Step 3: Implement the link operations** + +Add `from fileglancer.model import FileSharePath, NeuroglancerShortLink, ProxiedPath` to the client imports, then append to `fileglancer/client.py`: + +```python + # --- Data links --- + + def _absolutize(self, link: ProxiedPath) -> ProxiedPath: + """Rewrite a ProxiedPath's FSP-relative path to an absolute one. + + The REST API defines ProxiedPath.path as relative to the file share. + This client presents absolute paths throughout, so the value is + replaced here. fsp_name is left in place for reference. + """ + return link.model_copy( + update={"path": self.abspath(link.fsp_name, link.path)}) + + def create_data_link(self, path: str, + url_prefix: Optional[str] = None) -> ProxiedPath: + """Create a data link that serves a folder over HTTP. + + Args: + path: Absolute path to the folder to share. + url_prefix: The URL segment after the sharing key. Defaults to the + folder's basename. + """ + fsp_name, subpath = self._resolve(path) + params: Dict[str, Any] = {"fsp_name": fsp_name, "path": subpath} + if url_prefix is not None: + params["url_prefix"] = url_prefix + data = self._request("POST", "/api/proxied-path", params=params).json() + return self._absolutize(ProxiedPath(**data)) + + def data_links(self) -> List[ProxiedPath]: + """List the caller's data links.""" + data = self._request("GET", "/api/proxied-path").json() + return [self._absolutize(ProxiedPath(**p)) for p in data["paths"]] + + def data_link(self, sharing_key: str) -> ProxiedPath: + """Get one data link by its sharing key.""" + data = self._request("GET", f"/api/proxied-path/{sharing_key}").json() + return self._absolutize(ProxiedPath(**data)) + + def delete_data_link(self, sharing_key: str) -> None: + """Delete a data link.""" + self._request("DELETE", f"/api/proxied-path/{sharing_key}") + + # --- Neuroglancer links --- + + def create_ng_link(self, state: Dict[str, Any], + url_base: str = NEUROGLANCER_URL, + title: Optional[str] = None, + short_name: Optional[str] = None) -> str: + """Store a Neuroglancer state and return a shortened viewer URL. + + Args: + state: A Neuroglancer state as a plain dict. This is exactly what + neuroglancer.ViewerState.to_json() returns, so no dependency + on the neuroglancer package is needed. + url_base: The Neuroglancer instance the link should open in. + title: Optional title shown in the browser tab. + short_name: Optional human-friendly suffix for the link. + """ + payload: Dict[str, Any] = {"state": state, "url_base": url_base} + if title is not None: + payload["title"] = title + if short_name is not None: + payload["short_name"] = short_name + data = self._request("POST", "/api/neuroglancer/nglinks", + json=payload).json() + return data["neuroglancer_url"] + + def ng_links(self) -> List[NeuroglancerShortLink]: + """List the caller's stored Neuroglancer links.""" + data = self._request("GET", "/api/neuroglancer/nglinks").json() + return [NeuroglancerShortLink(**link) for link in data["links"]] + + def delete_ng_link(self, short_key: str) -> None: + """Delete a stored Neuroglancer link.""" + self._request("DELETE", f"/api/neuroglancer/nglinks/{short_key}") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_client_links.py -v` +Expected: PASS, 13 tests + +- [ ] **Step 5: Commit** + +```bash +git add fileglancer/client.py tests/test_client_links.py +git commit -m "feat: add data link and Neuroglancer link methods to the client" +``` + +--- + +### Task 8: Python client job operations + +**Files:** +- Modify: `fileglancer/client.py` +- Test: `tests/test_client_jobs.py` + +**Interfaces:** +- Consumes: `Fileglancer._request` (Task 5). +- Produces: + - `Fileglancer.jobs(status: Optional[str] = None) -> list[Job]` + - `Fileglancer.job(job_id: int) -> Job` + - `Fileglancer.submit_job(app_url: str, entry_point_id: str, **kwargs) -> Job` + - `Fileglancer.cancel_job(job_id: int) -> None` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_client_jobs.py`: + +```python +"""Client job operations. + +Job submission needs a real app manifest and scheduler, so these tests cover +the request shaping and scope enforcement rather than an end-to-end launch. +""" +import httpx +import pytest + +from fileglancer.client import Fileglancer, FileglancerError +from fileglancer.database import create_api_token + +from test_api_token_auth import token_app # noqa: F401 + + +@pytest.fixture +def fg(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "test", ["jobs:write"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + yield client + client.close() + + +def test_jobs_is_empty_for_a_new_user(fg): + assert fg.jobs() == [] + + +def test_jobs_accepts_a_status_filter(fg): + assert fg.jobs(status="RUNNING") == [] + + +def test_unknown_job_id_raises(fg): + with pytest.raises(FileglancerError) as excinfo: + fg.job(99999) + + assert excinfo.value.status_code == 404 + + +def test_submit_job_sends_app_url_and_entry_point(fg): + # No such app is registered, so this fails at the server. What matters is + # that the request was well-formed enough to reach that check. + with pytest.raises(FileglancerError) as excinfo: + fg.submit_job(app_url="https://github.com/example/none", + entry_point_id="main") + + assert excinfo.value.status_code in (400, 500) + + +def test_a_read_only_jobs_token_cannot_submit(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "ro", ["jobs:read"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + + with pytest.raises(FileglancerError) as excinfo: + client.submit_job(app_url="https://github.com/example/none", + entry_point_id="main") + + assert excinfo.value.status_code == 403 + client.close() + + +def test_a_links_only_token_cannot_list_jobs(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "links", ["links:read"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + + with pytest.raises(FileglancerError) as excinfo: + client.jobs() + + assert excinfo.value.status_code == 403 + client.close() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pixi run -e test test-backend -- tests/test_client_jobs.py -v` +Expected: FAIL with `AttributeError: 'Fileglancer' object has no attribute 'jobs'` + +- [ ] **Step 3: Implement the job operations** + +Add `Job` to the `fileglancer.model` import in the client, then append to `fileglancer/client.py`: + +```python + # --- Jobs --- + + def jobs(self, status: Optional[str] = None) -> List[Job]: + """List the caller's jobs, optionally filtered by status.""" + params = {"status": status} if status else None + data = self._request("GET", "/api/jobs", params=params).json() + return [Job(**job) for job in data["jobs"]] + + def job(self, job_id: int) -> Job: + """Get a single job by id.""" + return Job(**self._request("GET", f"/api/jobs/{job_id}").json()) + + def submit_job(self, app_url: str, entry_point_id: str, **kwargs) -> Job: + """Submit a job. + + Args: + app_url: The app's repository URL. + entry_point_id: Which entry point of the app to run. + **kwargs: Any other field accepted by the /api/jobs endpoint, such + as parameters, resources, name, env, or container. + """ + payload = {"app_url": app_url, "entry_point_id": entry_point_id, **kwargs} + return Job(**self._request("POST", "/api/jobs", json=payload).json()) + + def cancel_job(self, job_id: int) -> None: + """Cancel a running or pending job.""" + self._request("POST", f"/api/jobs/{job_id}/cancel") +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pixi run -e test test-backend -- tests/test_client_jobs.py -v` +Expected: PASS, 6 tests + +- [ ] **Step 5: Run the whole backend suite** + +Run: `pixi run -e test test-backend` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add fileglancer/client.py tests/test_client_jobs.py +git commit -m "feat: add job methods to the Python client" +``` + +--- + +### Task 9: Frontend token queries + +**Files:** +- Create: `frontend/src/queries/apiTokenQueries.ts` +- Test: `frontend/src/__tests__/apiTokenQueries.test.tsx` + +**Interfaces:** +- Consumes: `GET/POST/DELETE /api/tokens` (Task 4); `sendFetchRequest` from `@/utils`; `getResponseJsonOrError` and `throwResponseNotOkError` from `@/queries/queryUtils`. +- Produces: + - `export type ApiTokenInfo = { token_id, name, scopes, created_at, expires_at, last_used_at }` + - `export type CreateTokenResult = { token: ApiTokenInfo; secret: string }` + - `export const API_SCOPES: readonly string[]` + - `export const apiTokenQueryKeys = { all, list }` + - `export function useApiTokensQuery(): UseQueryResult` + - `export function useCreateApiTokenMutation(): UseMutationResult` + - `export function useDeleteApiTokenMutation(): UseMutationResult` + +This mirrors `frontend/src/queries/sshKeyQueries.ts` exactly; read that file first and follow its structure. + +- [ ] **Step 1: Write the failing test** + +Create `frontend/src/__tests__/apiTokenQueries.test.tsx`: + +```tsx +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + API_SCOPES, + useApiTokensQuery, + useCreateApiTokenMutation +} from '@/queries/apiTokenQueries'; + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return ( + {children} + ); +}; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('apiTokenQueries', () => { + it('exposes the six documented scopes', () => { + expect([...API_SCOPES].sort()).toEqual([ + 'files:read', + 'files:write', + 'jobs:read', + 'jobs:write', + 'links:read', + 'links:write' + ]); + }); + + it('returns the token list', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + tokens: [ + { + token_id: 'abc123', + name: 'laptop', + scopes: ['files:read'], + created_at: '2026-08-24T00:00:00Z', + expires_at: '2026-09-23T00:00:00Z', + last_used_at: null + } + ] + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + ); + + const { result } = renderHook(() => useApiTokensQuery(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].name).toBe('laptop'); + }); + + it('returns the one-time secret from a create', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + token: { + token_id: 'abc123', + name: 'laptop', + scopes: ['files:read'], + created_at: '2026-08-24T00:00:00Z', + expires_at: '2026-09-23T00:00:00Z', + last_used_at: null + }, + secret: 'fgt_abc123_supersecret' + }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ) + ) + ); + + const { result } = renderHook(() => useCreateApiTokenMutation(), { + wrapper + }); + const created = await result.current.mutateAsync({ + name: 'laptop', + scopes: ['files:read'], + expires_in_days: 30 + }); + + expect(created.secret).toBe('fgt_abc123_supersecret'); + }); + + it('surfaces an error response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ detail: 'Unknown scopes: nope' }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }) + ) + ); + + const { result } = renderHook(() => useCreateApiTokenMutation(), { + wrapper + }); + + await expect( + result.current.mutateAsync({ name: 'x', scopes: ['nope'] }) + ).rejects.toThrow(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pixi run test-frontend -- apiTokenQueries` +Expected: FAIL — cannot resolve `@/queries/apiTokenQueries` + +- [ ] **Step 3: Write the queries module** + +Create `frontend/src/queries/apiTokenQueries.ts`: + +```ts +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; + +import { sendFetchRequest } from '@/utils'; +import { + getResponseJsonOrError, + throwResponseNotOkError +} from '@/queries/queryUtils'; + +/** + * The scopes an API token can grant. Must match API_SCOPES in + * fileglancer/auth.py. + */ +export const API_SCOPES = [ + 'files:read', + 'files:write', + 'links:read', + 'links:write', + 'jobs:read', + 'jobs:write' +] as const; + +export type ApiScope = (typeof API_SCOPES)[number]; + +/** + * An API token, without its secret. + */ +export type ApiTokenInfo = { + token_id: string; + name: string; + scopes: string[]; + created_at: string; + expires_at: string; + last_used_at: string | null; +}; + +type ApiTokenListResponse = { + tokens: ApiTokenInfo[]; +}; + +/** + * The result of creating a token. The secret is shown once and is not + * recoverable afterwards. + */ +export type CreateTokenResult = { + token: ApiTokenInfo; + secret: string; +}; + +export type CreateTokenParams = { + name: string; + scopes: string[]; + expires_in_days?: number; +}; + +export const apiTokenQueryKeys = { + all: ['apiTokens'] as const, + list: () => ['apiTokens', 'list'] as const +}; + +const fetchApiTokens = async ( + signal?: AbortSignal +): Promise => { + const response = await sendFetchRequest('/api/tokens', 'GET', undefined, { + signal + }); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return (body as ApiTokenListResponse).tokens ?? []; +}; + +/** + * Query hook for the current user's API tokens. + */ +export function useApiTokensQuery(): UseQueryResult { + return useQuery({ + queryKey: apiTokenQueryKeys.list(), + queryFn: ({ signal }) => fetchApiTokens(signal) + }); +} + +/** + * Mutation hook for creating an API token. + * + * The returned secret is the only copy; the server keeps a hash. + */ +export function useCreateApiTokenMutation(): UseMutationResult< + CreateTokenResult, + Error, + CreateTokenParams +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: CreateTokenParams) => { + const response = await sendFetchRequest('/api/tokens', 'POST', { + name: params.name, + scopes: params.scopes, + expires_in_days: params.expires_in_days ?? 30 + }); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as CreateTokenResult; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: apiTokenQueryKeys.all }); + } + }); +} + +/** + * Mutation hook for revoking an API token by its public token_id. + */ +export function useDeleteApiTokenMutation(): UseMutationResult< + void, + Error, + string +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (tokenId: string) => { + // sendFetchRequest strips the body from DELETE requests, so the id goes + // in the path. + const response = await sendFetchRequest( + `/api/tokens/${encodeURIComponent(tokenId)}`, + 'DELETE' + ); + + if (!response.ok) { + const body = await getResponseJsonOrError(response); + throwResponseNotOkError(response, body); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: apiTokenQueryKeys.all }); + } + }); +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pixi run test-frontend -- apiTokenQueries` +Expected: PASS, 4 tests + +- [ ] **Step 5: Lint, format, and typecheck** + +```bash +pixi run node-eslint-write +pixi run node-prettier-write +pixi run node-check +``` + +Expected: no new TypeScript errors. Five pre-existing errors in `ContextMenu`, `PathFormatOptions`, `PermissionsTable`, `sshKeyQueries`, and `ColorsPageSync.test` are known and not blocking. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/queries/apiTokenQueries.ts frontend/src/__tests__/apiTokenQueries.test.tsx +git commit -m "feat: add frontend queries for API tokens" +``` + +--- + +### Task 10: API tokens page, route, and navigation + +**Files:** +- Create: `frontend/src/components/ApiTokens.tsx` +- Create: `frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx` +- Modify: `frontend/src/App.tsx` (import near line 27; route near line 153) +- Modify: `frontend/src/components/ui/Navbar/ProfileMenu.tsx` (menu item near line 72) + +**Interfaces:** +- Consumes: `useApiTokensQuery`, `useDeleteApiTokenMutation`, `ApiTokenInfo` (Task 9). +- Produces: + - Default export `ApiTokens` from `@/components/ApiTokens` + - `ApiTokenCard` taking `{ token: ApiTokenInfo }` + - A route at `/api-tokens` and a Profile menu entry linking to it + +Follow `frontend/src/components/SSHKeys.tsx` for the page shell: heading, explanatory paragraph, loading spinner, error card with retry, empty state, and list. + +- [ ] **Step 1: Write the token card** + +Create `frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx`: + +```tsx +import { Card, Typography } from '@material-tailwind/react'; +import { HiOutlineTrash } from 'react-icons/hi'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import type { ApiTokenInfo } from '@/queries/apiTokenQueries'; + +function formatDate(value: string | null): string { + if (!value) { + return 'Never'; + } + return new Date(value).toLocaleDateString(); +} + +export default function ApiTokenCard({ + token, + onRevoke, + isRevoking +}: { + readonly token: ApiTokenInfo; + readonly onRevoke: (tokenId: string) => void; + readonly isRevoking: boolean; +}) { + const isExpired = new Date(token.expires_at) < new Date(); + + return ( + +
+
+ + {token.name} + {isExpired ? ( + + Expired + + ) : null} + + + {token.scopes.join(', ')} + + + Created {formatDate(token.created_at)} · Expires{' '} + {formatDate(token.expires_at)} · Last used{' '} + {formatDate(token.last_used_at)} + +
+ onRevoke(token.token_id)} + size="sm" + > + Revoke + +
+
+ ); +} +``` + +- [ ] **Step 2: Write the page** + +Create `frontend/src/components/ApiTokens.tsx`. Model it on `SSHKeys.tsx`; the create dialog referenced here lands in Task 11, so for now wire the button to a `useState` that is not yet consumed: + +```tsx +import { useState } from 'react'; +import { Card, Typography } from '@material-tailwind/react'; +import { HiOutlineKey, HiOutlinePlus } from 'react-icons/hi'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import ApiTokenCard from '@/components/ui/ApiTokens/ApiTokenCard'; +import { Spinner } from '@/components/ui/widgets/Loaders'; +import { + useApiTokensQuery, + useDeleteApiTokenMutation +} from '@/queries/apiTokenQueries'; + +export default function ApiTokens() { + const [showCreateDialog, setShowCreateDialog] = useState(false); + const { data, isLoading, error, refetch } = useApiTokensQuery(); + const deleteToken = useDeleteApiTokenMutation(); + + const tokens = data ?? []; + const hasTokens = tokens.length > 0; + + return ( + <> + + API Tokens + + + + API tokens let scripts and notebooks use Fileglancer through the{' '} + fileglancer Python package. A token acts on your behalf, + limited to the scopes you grant it. The token is shown only once, when + you create it. + + + {isLoading ? ( +
+ +
+ ) : null} + + {error ? ( + + + Failed to load API tokens: {error.message} + + refetch()} size="sm"> + Retry + + + ) : null} + + {!isLoading && !error && !hasTokens ? ( + + + + No API tokens + + + Create a token to use Fileglancer from Python. + + setShowCreateDialog(true)} + size="sm" + > + New Token + + + ) : null} + + {!isLoading && !error && hasTokens ? ( +
+
+ setShowCreateDialog(true)} + size="sm" + > + New Token + +
+
+ {tokens.map(token => ( + deleteToken.mutate(id)} + token={token} + /> + ))} +
+
+ ) : null} + + ); +} +``` + +- [ ] **Step 3: Register the route** + +In `frontend/src/App.tsx`, add the import beside the `SSHKeys` import at line 27: + +```tsx +import ApiTokens from '@/components/ApiTokens'; +``` + +And add the route inside the `OtherPagesLayout` block, after the `ssh-keys` route that ends at line 163: + +```tsx + + + + } + path="api-tokens" + /> +``` + +The page is not behind a feature flag: token auth is core, not an integration. + +- [ ] **Step 4: Add the navigation entry** + +In `frontend/src/components/ui/Navbar/ProfileMenu.tsx`, add after the SSH Keys menu item (ends around line 81). Import `HiOutlineCode` from `react-icons/hi` alongside the existing icon imports: + +```tsx + + + API Tokens + +``` + +- [ ] **Step 5: Verify it renders** + +```bash +pixi run node-build +pixi run dev-launch +``` + +Open http://localhost:7878/api-tokens. Expected: the heading, the explanatory paragraph, and the "No API tokens" empty state with a "New Token" button. The button does nothing yet; the dialog is Task 11. + +- [ ] **Step 6: Lint, format, and typecheck** + +```bash +pixi run node-eslint-write +pixi run node-prettier-write +pixi run node-check +``` + +Expected: no new TypeScript errors beyond the five known pre-existing ones. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/components/ApiTokens.tsx frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx frontend/src/App.tsx frontend/src/components/ui/Navbar/ProfileMenu.tsx +git commit -m "feat: add API tokens page with list and revoke" +``` + +--- + +### Task 11: Create-token dialog and one-time secret display + +**Files:** +- Create: `frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx` +- Create: `frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx` +- Modify: `frontend/src/components/ApiTokens.tsx` + +**Interfaces:** +- Consumes: `useCreateApiTokenMutation`, `API_SCOPES`, `CreateTokenResult` (Task 9). +- Produces: + - `CreateTokenDialog` taking `{ showDialog, setShowDialog, onTokenCreated }` + - `NewTokenDialog` taking `{ result, onClose }`, showing the secret once with a copy button and the environment-variable snippet + +Read `frontend/src/components/ui/SSHKeys/GenerateTempKeyDialog.tsx` and `TempKeyDialog.tsx` first and follow their dialog structure, since they already solve "show a secret exactly once." + +- [ ] **Step 1: Write the create dialog** + +Create `frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx`: + +```tsx +import { useState } from 'react'; +import { Dialog, Typography } from '@material-tailwind/react'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import { + API_SCOPES, + useCreateApiTokenMutation +} from '@/queries/apiTokenQueries'; +import type { CreateTokenResult } from '@/queries/apiTokenQueries'; + +const EXPIRY_OPTIONS = [ + { days: 30, label: '30 days' }, + { days: 90, label: '90 days' }, + { days: 365, label: '365 days' } +]; + +export default function CreateTokenDialog({ + showDialog, + setShowDialog, + onTokenCreated +}: { + readonly showDialog: boolean; + readonly setShowDialog: (show: boolean) => void; + readonly onTokenCreated: (result: CreateTokenResult) => void; +}) { + const [name, setName] = useState(''); + const [scopes, setScopes] = useState(['files:read']); + const [expiryDays, setExpiryDays] = useState(30); + const createToken = useCreateApiTokenMutation(); + + const canSubmit = name.trim().length > 0 && scopes.length > 0; + + const toggleScope = (scope: string) => { + setScopes(current => + current.includes(scope) + ? current.filter(s => s !== scope) + : [...current, scope] + ); + }; + + const handleSubmit = async () => { + const result = await createToken.mutateAsync({ + name: name.trim(), + scopes, + expires_in_days: expiryDays + }); + setName(''); + setScopes(['files:read']); + setExpiryDays(30); + setShowDialog(false); + onTokenCreated(result); + }; + + return ( + + + + + New API Token + + + + +
+ + Scopes + + {API_SCOPES.map(scope => ( + + ))} + + A :write scope also grants :read. + +
+ + + + {createToken.error ? ( + + {createToken.error.message} + + ) : null} + +
+ setShowDialog(false)} size="sm"> + Cancel + + + Create + +
+
+
+
+ ); +} +``` + +- [ ] **Step 2: Write the one-time secret dialog** + +Create `frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx`: + +```tsx +import { Dialog, Typography } from '@material-tailwind/react'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import type { CreateTokenResult } from '@/queries/apiTokenQueries'; + +export default function NewTokenDialog({ + result, + onClose +}: { + readonly result: CreateTokenResult | null; + readonly onClose: () => void; +}) { + if (!result) { + return null; + } + + const snippet = `export FILEGLANCER_URL=${window.location.origin}\nexport FILEGLANCER_TOKEN=${result.secret}`; + + return ( + + + + + Token created + + + + Copy this now. It will not be shown again. + + +
+            {snippet}
+          
+ +
+ navigator.clipboard.writeText(snippet)} + size="sm" + > + Copy + + + Done + +
+
+
+
+ ); +} +``` + +- [ ] **Step 3: Wire both dialogs into the page** + +In `frontend/src/components/ApiTokens.tsx`, add the imports: + +```tsx +import CreateTokenDialog from '@/components/ui/ApiTokens/CreateTokenDialog'; +import NewTokenDialog from '@/components/ui/ApiTokens/NewTokenDialog'; +import type { CreateTokenResult } from '@/queries/apiTokenQueries'; +``` + +Add the state alongside `showCreateDialog`: + +```tsx + const [newToken, setNewToken] = useState(null); +``` + +And render both dialogs just before the closing ``: + +```tsx + + + setNewToken(null)} result={newToken} /> +``` + +- [ ] **Step 4: Verify the flow by hand** + +```bash +pixi run node-build +pixi run dev-launch +``` + +At http://localhost:7878/api-tokens: click "New Token", enter a name, check `files:read`, create. Expected: the secret dialog appears with an `fgt_` token and both export lines; after "Done", the list shows the new token with its scopes and expiry, and the secret is nowhere in the listing. + +- [ ] **Step 5: Verify the token actually works end to end** + +Copy the two export lines from the dialog into a shell, then: + +```bash +pixi run python -c " +from fileglancer import Fileglancer +fg = Fileglancer() +print([f.name for f in fg.file_share_paths()]) +" +``` + +Expected: the list of file share names. This is the first full-loop check that GUI-minted tokens authenticate the Python client. + +- [ ] **Step 6: Lint, format, and typecheck** + +```bash +pixi run node-eslint-write +pixi run node-prettier-write +pixi run node-check +``` + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx frontend/src/components/ApiTokens.tsx +git commit -m "feat: add create-token dialog and one-time secret display" +``` + +--- + +### Task 12: End-to-end test + +**Files:** +- Create: `frontend/ui-tests/tests/apiTokens.spec.ts` + +**Interfaces:** +- Consumes: the `/api-tokens` page (Tasks 10 and 11). +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Write the spec** + +Authentication is handled by the shared fixture at `frontend/ui-tests/fixtures/fileglancer-fixture`, which every existing spec imports instead of `@playwright/test`. Importing `test` from there is the whole login setup; there is no explicit login step to write. + +Create `frontend/ui-tests/tests/api-tokens.spec.ts`: + +```ts +import { expect, test } from '../fixtures/fileglancer-fixture'; + +test.describe('API tokens', () => { + test('create, display once, and revoke a token', async ({ page }) => { + await page.goto('/api-tokens', { waitUntil: 'domcontentloaded' }); + + await expect( + page.getByRole('heading', { name: 'API Tokens' }) + ).toBeVisible(); + + await page.getByRole('button', { name: 'New Token' }).click(); + await page.getByPlaceholder('laptop notebook').fill('e2e token'); + await page.getByRole('button', { name: 'Create' }).click(); + + // The secret is shown exactly once, in the confirmation dialog. + await expect(page.getByText('Copy this now')).toBeVisible(); + await expect(page.getByText(/FILEGLANCER_TOKEN=fgt_/)).toBeVisible(); + + await page.getByRole('button', { name: 'Done' }).click(); + + // The listing shows the token but never its secret. + await expect(page.getByText('e2e token')).toBeVisible(); + await expect(page.getByText(/fgt_/)).toHaveCount(0); + + await page.getByRole('button', { name: 'Revoke' }).click(); + await expect(page.getByText('No API tokens')).toBeVisible(); + }); +}); +``` + +- [ ] **Step 2: Run the spec** + +```bash +pixi run test-ui -- tests/api-tokens.spec.ts +``` + +Expected: PASS. If Playwright browsers are not installed: + +```bash +pixi run node-install-ui-tests +cd frontend/ui-tests && pixi run npx playwright install +``` + +- [ ] **Step 3: Commit** + +```bash +git add frontend/ui-tests/tests/api-tokens.spec.ts +git commit -m "test: add end-to-end spec for API token creation and revocation" +``` + +--- + +### Task 13: Documentation + +**Files:** +- Create: `../fileglancer-docs/src/content/docs/features/python-api.mdx` + +**Interfaces:** +- Consumes: everything above. +- Produces: nothing consumed by later tasks. + +The docs site is a separate Astro Starlight repository at `../fileglancer-docs`. `features/` holds capability pages (`ssh-keys.mdx` is the closest neighbour — also a credential-driven capability); `workflows/` holds task walkthroughs. This page belongs in `features/`. + +- [ ] **Step 1: Write the page** + +Create `../fileglancer-docs/src/content/docs/features/python-api.mdx` with the content below. Read `features/ssh-keys.mdx` first and match its heading style. Remember: one line per paragraph, no hard wrapping. + +````markdown +--- +title: Python API +description: Use Fileglancer from Python with an API token +--- + +Fileglancer's HTTP API can be driven from Python using an API token. The client ships inside the same `fileglancer` package as the command-line tool. + +## Create a token + +Open the profile menu and choose **API Tokens**, then **New Token**. + +Give the token a name you will recognise later, choose the scopes it needs, and pick an expiry. Tokens last 30 days by default and at most 365 days. + +| Scope | Grants | +| --- | --- | +| `files:read` | List directories and read file contents | +| `files:write` | Create, rename, delete, and write files | +| `links:read` | List data links and Neuroglancer links | +| `links:write` | Create and delete data links and Neuroglancer links | +| `jobs:read` | List jobs | +| `jobs:write` | Submit and cancel jobs | + +A `:write` scope also grants the matching `:read`. + +The token is shown exactly once, when you create it. Copy it then; it cannot be recovered afterwards. If you lose it, revoke it and create another. + +Tokens cannot reach every endpoint. SSH keys, app management, preferences, and token management itself are available only in the web interface, so a leaked token cannot be used to mint another one. + +## Connect + +The client reads two environment variables: + +```bash +export FILEGLANCER_URL=https://your-fileglancer-server +export FILEGLANCER_TOKEN=fgt_... +``` + +```python +from fileglancer import Fileglancer + +fg = Fileglancer() +``` + +You can also pass them directly, which takes precedence over the environment: + +```python +fg = Fileglancer(url="https://your-fileglancer-server", token="fgt_...") +``` + +## Paths + +Every method takes an absolute filesystem path, the same path you would use on the command line. + +```python +fg.ls("/nearline/alice") +fg.mkdir("/nearline/alice/analysis") +fg.write("/nearline/alice/notes.txt", b"hello") +fg.read("/nearline/alice/notes.txt") +fg.rename("/nearline/alice/a.zarr", "/nearline/alice/b.zarr") +fg.delete("/nearline/alice/tmp") +``` + +Paths in Mac (`smb://...`) and Windows (`\\server\share\...`) form are accepted too. Paths returned by the client are always in Linux form. + +`fg.file_share_paths()` lists the shares available to you. If a path matches no share, the error names the ones that exist. + +## Data links + +A data link serves a folder over HTTP so that a viewer can read it. + +```python +link = fg.create_data_link("/nearline/alice/sample.zarr") +print(link.url) + +fg.data_links() +fg.delete_data_link(link.sharing_key) +``` + +## Neuroglancer links + +`create_ng_link` takes a Neuroglancer state as a plain dictionary, which is what `neuroglancer.ViewerState.to_json()` produces. + +```python +import neuroglancer +from fileglancer import Fileglancer + +fg = Fileglancer() +link = fg.create_data_link("/nearline/alice/sample.zarr") + +state = neuroglancer.ViewerState() +state.layers["sample"] = neuroglancer.ImageLayer(source=f"zarr://{link.url}") + +print(fg.create_ng_link(state.to_json(), title="sample")) +``` + +This needs `links:write` on the token, and `neuroglancer` installed separately — the Fileglancer client does not depend on it. + +Pass `url_base` to open the link in a different Neuroglancer instance. + +## Jobs + +```python +fg.jobs() +fg.jobs(status="RUNNING") +fg.job(job_id) +fg.cancel_job(job_id) +``` + +## Errors + +Anything the server rejects raises `FileglancerError`, carrying the server's message and its HTTP status code. + +```python +from fileglancer import FileglancerError + +try: + fg.mkdir("/nearline/alice/new") +except FileglancerError as error: + print(error, error.status_code) +``` + +A 403 usually means the token is missing a scope; the message names the one it needs. +```` + +- [ ] **Step 2: Verify the docs site builds** + +Follow the build instructions in `../fileglancer-docs/README.md` and confirm the new page renders and appears in the Features section of the navigation. + +- [ ] **Step 3: Commit** + +The docs site is a separate repository. Commit there: + +```bash +cd ../fileglancer-docs +git add src/content/docs/features/python-api.mdx +git commit -m "docs: add Python API page" +``` + +Do not push either repository. Report both commits and let the user decide when to push. + +--- + +## Final verification + +- [ ] Run the full backend suite: `pixi run -e test test-backend` +- [ ] Run the frontend unit tests: `pixi run test-frontend` +- [ ] Run the end-to-end tests: `pixi run test-ui` +- [ ] Run the checks the pre-push hook runs: `pixi run node-prettier-check && pixi run node-eslint-check` +- [ ] Confirm `pixi run migrate` reaches head `f2a8c1d94e60` on a fresh database From cb439b30d912c21ebb3cc3f775d7af56ab3d6f86 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 18:51:34 -0400 Subject: [PATCH 03/43] feat: add api_tokens table and CRUD functions --- .../f2a8c1d94e60_add_api_tokens_table.py | 40 ++++++ fileglancer/database.py | 119 ++++++++++++++++ tests/test_api_tokens.py | 132 ++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py create mode 100644 tests/test_api_tokens.py diff --git a/fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py b/fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py new file mode 100644 index 000000000..8203be60a --- /dev/null +++ b/fileglancer/alembic/versions/f2a8c1d94e60_add_api_tokens_table.py @@ -0,0 +1,40 @@ +"""add api_tokens table + +Revision ID: f2a8c1d94e60 +Revises: e7b2a9c4f130 +Create Date: 2026-08-24 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f2a8c1d94e60' +down_revision = 'e7b2a9c4f130' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + 'api_tokens', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('token_id', sa.String(), nullable=False), + sa.Column('token_hash', sa.String(), nullable=False), + sa.Column('username', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('scopes', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('expires_at', sa.DateTime(), nullable=False), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_api_tokens_token_id', 'api_tokens', ['token_id'], unique=True) + op.create_index('ix_api_tokens_username', 'api_tokens', ['username']) + + +def downgrade() -> None: + op.drop_index('ix_api_tokens_username', table_name='api_tokens') + op.drop_index('ix_api_tokens_token_id', table_name='api_tokens') + op.drop_table('api_tokens') diff --git a/fileglancer/database.py b/fileglancer/database.py index 1c861b084..8e7034d1a 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -273,6 +273,37 @@ class SessionDB(Base): last_accessed_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) +# --- API token constants --- + +API_TOKEN_PREFIX = "fgt" +API_TOKEN_ID_LENGTH = 12 +DEFAULT_TOKEN_EXPIRY_DAYS = 30 +MAX_TOKEN_EXPIRY_DAYS = 365 + +# last_used_at is refreshed at most this often, so token auth does not cost a +# database write on every single API request. +TOKEN_TOUCH_INTERVAL_SECONDS = 300 + + +class ApiTokenDB(Base): + """Database model for programmatic API tokens. + + Only the SHA-256 hash of the secret half is stored. The full token string + is returned exactly once, at creation, and is not recoverable afterwards. + """ + __tablename__ = 'api_tokens' + + id = Column(Integer, primary_key=True, autoincrement=True) + token_id = Column(String, nullable=False, unique=True, index=True) + token_hash = Column(String, nullable=False) + username = Column(String, nullable=False, index=True) + name = Column(String, nullable=False) + scopes = Column(String, nullable=False) + created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) + expires_at = Column(DateTime, nullable=False) + last_used_at = Column(DateTime, nullable=True) + + def run_alembic_upgrade(db_url): """Run Alembic migrations to upgrade database to latest version""" global _migrations_run @@ -989,6 +1020,94 @@ def delete_expired_sessions(session: Session): return deleted +# --- API token functions --- + +def hash_token_secret(secret: str) -> str: + """Hash the secret half of an API token. + + ponytail: SHA-256 rather than a slow KDF. The secret is 32 bytes from + secrets.token_urlsafe, not a human-chosen password, so key stretching + defends against nothing here. Revisit only if user-chosen secrets are + ever allowed. + """ + return hashlib.sha256(secret.encode('utf-8')).hexdigest() + + +def create_api_token(session: Session, username: str, name: str, + scopes: List[str], + expires_in_days: int = DEFAULT_TOKEN_EXPIRY_DAYS + ) -> Tuple[ApiTokenDB, str]: + """Create an API token for a user. + + Returns (row, plaintext_token). The plaintext token is the only time the + secret is available; only its hash is persisted. + """ + if not 1 <= expires_in_days <= MAX_TOKEN_EXPIRY_DAYS: + raise ValueError( + f"expires_in_days must be between 1 and {MAX_TOKEN_EXPIRY_DAYS}") + + token_id = secrets.token_urlsafe(16)[:API_TOKEN_ID_LENGTH] + secret = secrets.token_urlsafe(32) + now = datetime.now(UTC) + + row = ApiTokenDB( + token_id=token_id, + token_hash=hash_token_secret(secret), + username=username, + name=name, + scopes=" ".join(sorted(scopes)), + created_at=now, + expires_at=now + timedelta(days=expires_in_days), + last_used_at=None, + ) + session.add(row) + session.commit() + return row, f"{API_TOKEN_PREFIX}_{token_id}_{secret}" + + +def get_api_token_by_id(session: Session, token_id: str) -> Optional[ApiTokenDB]: + """Get an API token row by its public token_id.""" + return session.query(ApiTokenDB).filter_by(token_id=token_id).first() + + +def list_api_tokens(session: Session, username: str) -> List[ApiTokenDB]: + """List a user's API tokens, newest first.""" + return (session.query(ApiTokenDB) + .filter_by(username=username) + .order_by(ApiTokenDB.created_at.desc()) + .all()) + + +def delete_api_token(session: Session, username: str, token_id: str) -> int: + """Revoke a token. Returns the number of rows deleted (0 or 1). + + Filtering on username as well as token_id means one user cannot revoke + another user's token by guessing its id. + """ + deleted = (session.query(ApiTokenDB) + .filter_by(username=username, token_id=token_id) + .delete()) + session.commit() + return deleted + + +def touch_api_token(session: Session, token_id: str) -> None: + """Refresh last_used_at, at most once per TOKEN_TOUCH_INTERVAL_SECONDS.""" + row = get_api_token_by_id(session, token_id) + if row is None: + return + + now = datetime.now(UTC) + if row.last_used_at is not None: + # SQLAlchemy strips tzinfo on read; add it back before comparing. + last_used = row.last_used_at.replace(tzinfo=UTC) + if (now - last_used).total_seconds() < TOKEN_TOUCH_INTERVAL_SECONDS: + return + + row.last_used_at = now + session.commit() + + # --- Job database functions --- TERMINAL_JOB_STATUSES = ("DONE", "FAILED", "KILLED") diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py new file mode 100644 index 000000000..f430a696f --- /dev/null +++ b/tests/test_api_tokens.py @@ -0,0 +1,132 @@ +"""Tests for API token storage and CRUD.""" +from datetime import datetime, timedelta, UTC + +import pytest + +from fileglancer.database import ( + ApiTokenDB, + Base, + MAX_TOKEN_EXPIRY_DAYS, + create_api_token, + create_engine, + delete_api_token, + get_api_token_by_id, + hash_token_secret, + list_api_tokens, + sessionmaker, + touch_api_token, +) + + +@pytest.fixture +def db_session(tmp_path): + engine = create_engine(f"sqlite:///{tmp_path / 'tokens.db'}") + Base.metadata.create_all(engine) + session = sessionmaker(bind=engine)() + yield session + session.close() + engine.dispose() + + +def test_create_token_returns_plaintext_matching_stored_hash(db_session): + row, plaintext = create_api_token(db_session, "alice", "laptop", ["files:read"]) + + prefix, token_id, secret = plaintext.split("_", 2) + assert prefix == "fgt" + assert token_id == row.token_id + assert len(token_id) == 12 + assert hash_token_secret(secret) == row.token_hash + # The secret itself is never stored. + assert secret not in row.token_hash + + +def test_create_token_defaults_to_30_day_expiry(db_session): + row, _ = create_api_token(db_session, "alice", "laptop", ["files:read"]) + + expires_at = row.expires_at.replace(tzinfo=UTC) + delta = expires_at - datetime.now(UTC) + assert timedelta(days=29) < delta <= timedelta(days=30) + + +def test_create_token_rejects_expiry_above_maximum(db_session): + with pytest.raises(ValueError, match="between 1 and 365"): + create_api_token(db_session, "alice", "laptop", ["files:read"], + expires_in_days=MAX_TOKEN_EXPIRY_DAYS + 1) + + +def test_create_token_rejects_zero_day_expiry(db_session): + with pytest.raises(ValueError, match="between 1 and 365"): + create_api_token(db_session, "alice", "laptop", ["files:read"], + expires_in_days=0) + + +def test_scopes_are_stored_space_separated_and_sorted(db_session): + row, _ = create_api_token(db_session, "alice", "laptop", + ["links:write", "files:read"]) + assert row.scopes == "files:read links:write" + + +def test_token_ids_are_unique_across_creations(db_session): + ids = {create_api_token(db_session, "alice", f"t{i}", ["files:read"])[0].token_id + for i in range(50)} + assert len(ids) == 50 + + +def test_get_by_id_returns_none_for_unknown_id(db_session): + assert get_api_token_by_id(db_session, "doesnotexist") is None + + +def test_list_returns_only_the_callers_tokens(db_session): + create_api_token(db_session, "alice", "a", ["files:read"]) + create_api_token(db_session, "bob", "b", ["files:read"]) + + names = [t.name for t in list_api_tokens(db_session, "alice")] + assert names == ["a"] + + +def test_delete_removes_the_row_and_returns_one(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + + assert delete_api_token(db_session, "alice", row.token_id) == 1 + assert get_api_token_by_id(db_session, row.token_id) is None + + +def test_delete_refuses_another_users_token(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + + assert delete_api_token(db_session, "bob", row.token_id) == 0 + assert get_api_token_by_id(db_session, row.token_id) is not None + + +def test_touch_sets_last_used_when_never_used(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + assert row.last_used_at is None + + touch_api_token(db_session, row.token_id) + + assert get_api_token_by_id(db_session, row.token_id).last_used_at is not None + + +def test_touch_is_a_noop_when_recently_used(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + touch_api_token(db_session, row.token_id) + first = get_api_token_by_id(db_session, row.token_id).last_used_at + + touch_api_token(db_session, row.token_id) + + assert get_api_token_by_id(db_session, row.token_id).last_used_at == first + + +def test_touch_updates_when_stale(db_session): + row, _ = create_api_token(db_session, "alice", "a", ["files:read"]) + row.last_used_at = datetime.now(UTC) - timedelta(hours=1) + db_session.commit() + stale = row.last_used_at + + touch_api_token(db_session, row.token_id) + + assert get_api_token_by_id(db_session, row.token_id).last_used_at > stale + + +def test_touch_ignores_unknown_token(db_session): + touch_api_token(db_session, "doesnotexist") # must not raise From 45c147f163a577ad0a347cec7c7d132168e46be4 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 18:58:14 -0400 Subject: [PATCH 04/43] fix: use token_hex for token_id to avoid delimiter collision --- fileglancer/database.py | 5 ++++- tests/test_api_tokens.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/fileglancer/database.py b/fileglancer/database.py index 8e7034d1a..4ec160162 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -1046,7 +1046,10 @@ def create_api_token(session: Session, username: str, name: str, raise ValueError( f"expires_in_days must be between 1 and {MAX_TOKEN_EXPIRY_DAYS}") - token_id = secrets.token_urlsafe(16)[:API_TOKEN_ID_LENGTH] + # token_hex, not token_urlsafe: the base64url alphabet contains '_', which + # is the delimiter in the fgt__ format, so ~17% of + # urlsafe ids would break parsing and be permanently unusable. + token_id = secrets.token_hex(API_TOKEN_ID_LENGTH // 2) secret = secrets.token_urlsafe(32) now = datetime.now(UTC) diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py index f430a696f..7cc9ed266 100644 --- a/tests/test_api_tokens.py +++ b/tests/test_api_tokens.py @@ -130,3 +130,19 @@ def test_touch_updates_when_stale(db_session): def test_touch_ignores_unknown_token(db_session): touch_api_token(db_session, "doesnotexist") # must not raise + + +def test_token_id_contains_no_format_delimiter(db_session): + """token_id must never contain '_', the delimiter in fgt__. + + A token_id containing '_' parses to a truncated id, so the token is + permanently unusable. Many iterations because the old implementation + failed only ~17% of the time. + """ + for i in range(200): + row, plaintext = create_api_token(db_session, "alice", f"t{i}", + ["files:read"]) + assert "_" not in row.token_id + prefix, token_id, secret = plaintext.split("_", 2) + assert token_id == row.token_id + assert hash_token_secret(secret) == row.token_hash From 44f9c3ba2e967dee8534236803d9f97e595be79f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:00:45 -0400 Subject: [PATCH 05/43] feat: add API token scope table and matching logic --- fileglancer/auth.py | 77 +++++++++++++++++++++++++++++++++++ tests/test_api_scopes.py | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 tests/test_api_scopes.py diff --git a/fileglancer/auth.py b/fileglancer/auth.py index 6cf3e101b..f3ad3240d 100644 --- a/fileglancer/auth.py +++ b/fileglancer/auth.py @@ -209,3 +209,80 @@ def delete_session_cookie(response: Response, settings: Settings): secure=settings.session_cookie_secure, samesite='lax' ) + + +# --- API token scopes --- + +API_SCOPES = frozenset({ + "files:read", "files:write", + "links:read", "links:write", + "jobs:read", "jobs:write", +}) + +# Sentinel meaning "any valid token may call this", as distinct from None, +# which means "no token may call this at all". +ANY_SCOPE = "" + +# Maps a request path prefix to the resource whose scope guards it. Order does +# not matter; prefixes are mutually exclusive. +# +# Deliberately absent: /api/file-share-paths, /api/external-buckets and +# /api/version have no get_current_user dependency and are already +# unauthenticated, so this table is never consulted for them. That is what +# lets the Python client resolve paths regardless of a token's scopes. +_SCOPE_PREFIXES = ( + ("/api/files", "files"), + ("/api/content", "files"), + ("/api/proxied-path", "links"), + ("/api/neuroglancer", "links"), + ("/api/jobs", "jobs"), + ("/api/cluster-defaults", "jobs"), +) + +# Readable by any valid token regardless of scope: identity and liveness. +_ANY_SCOPE_PATHS = ("/api/profile", "/api/auth/status") + +_READ_METHODS = ("GET", "HEAD") + + +def _path_matches(path: str, prefix: str) -> bool: + """True when path is the prefix itself or a child of it. + + The explicit '/' check is what stops '/api/filesystem-admin' from being + treated as a child of '/api/files'. + """ + return path == prefix or path.startswith(prefix + "/") + + +def required_scope(path: str, method: str) -> Optional[str]: + """Return the scope an API token needs to call path with method. + + Returns ANY_SCOPE when any valid token suffices, and None when the path is + not reachable with a token at all. None is the default: a path that is not + listed here is session-only, which is what keeps /api/ssh-keys, + /api/tokens, /api/apps, /api/catalog, /api/preference and /api/ticket out + of reach without naming any of them. + """ + if any(_path_matches(path, p) for p in _ANY_SCOPE_PATHS): + return ANY_SCOPE + + for prefix, resource in _SCOPE_PREFIXES: + if _path_matches(path, prefix): + action = "read" if method.upper() in _READ_METHODS else "write" + return f"{resource}:{action}" + + return None + + +def token_has_scope(granted: str, required: str) -> bool: + """Check a token's space-separated scope string against a requirement.""" + if required == ANY_SCOPE: + return True + + held = set(granted.split()) + if required in held: + return True + + # ':write' implies ':read' for the same resource. + resource, _, action = required.partition(":") + return action == "read" and f"{resource}:write" in held diff --git a/tests/test_api_scopes.py b/tests/test_api_scopes.py new file mode 100644 index 000000000..1f845eee7 --- /dev/null +++ b/tests/test_api_scopes.py @@ -0,0 +1,86 @@ +"""Tests for the API token scope table and scope matching. + +Both functions under test are pure, so these are plain table-driven tests +with no database or app fixture. +""" +import pytest + +from fileglancer.auth import ( + ANY_SCOPE, + API_SCOPES, + required_scope, + token_has_scope, +) + + +def test_api_scopes_are_exactly_the_six_documented_names(): + assert API_SCOPES == frozenset({ + "files:read", "files:write", + "links:read", "links:write", + "jobs:read", "jobs:write", + }) + + +@pytest.mark.parametrize("path,method,expected", [ + ("/api/files/tempdir", "GET", "files:read"), + ("/api/files/tempdir", "POST", "files:write"), + ("/api/files/tempdir", "PATCH", "files:write"), + ("/api/files/tempdir", "DELETE", "files:write"), + ("/api/content/tempdir/a.txt", "GET", "files:read"), + ("/api/content/tempdir/a.txt", "HEAD", "files:read"), + ("/api/content/tempdir/a.txt", "PUT", "files:write"), + ("/api/proxied-path", "GET", "links:read"), + ("/api/proxied-path", "POST", "links:write"), + ("/api/proxied-path/abc123", "DELETE", "links:write"), + ("/api/neuroglancer/nglinks", "GET", "links:read"), + ("/api/neuroglancer/nglinks", "POST", "links:write"), + ("/api/jobs", "GET", "jobs:read"), + ("/api/jobs", "POST", "jobs:write"), + ("/api/jobs/12/cancel", "POST", "jobs:write"), + ("/api/cluster-defaults", "GET", "jobs:read"), +]) +def test_required_scope_maps_path_and_method(path, method, expected): + assert required_scope(path, method) == expected + + +@pytest.mark.parametrize("path", ["/api/profile", "/api/auth/status"]) +def test_paths_readable_by_any_valid_token(path): + assert required_scope(path, "GET") == ANY_SCOPE + + +@pytest.mark.parametrize("path", [ + "/api/ssh-keys", + "/api/ssh-keys/generate-temp", + "/api/tokens", + "/api/tokens/abc123", + "/api/apps", + "/api/catalog", + "/api/preference/foo", + "/api/ticket", +]) +def test_unlisted_paths_are_not_token_reachable(path): + assert required_scope(path, "GET") is None + + +def test_prefix_match_does_not_leak_across_similar_paths(): + # "/api/filesystem-admin" must not match the "/api/files" prefix. + assert required_scope("/api/filesystem-admin", "GET") is None + + +@pytest.mark.parametrize("granted,required,expected", [ + ("files:read", "files:read", True), + ("files:read", "files:write", False), + ("files:write", "files:read", True), # write implies read + ("files:write", "files:write", True), + ("files:read links:write", "links:read", True), + ("files:read", "links:read", False), + ("files:write", "jobs:read", False), + ("", "files:read", False), +]) +def test_token_has_scope(granted, required, expected): + assert token_has_scope(granted, required) is expected + + +@pytest.mark.parametrize("granted", ["", "files:read", "jobs:write"]) +def test_any_scope_is_satisfied_by_any_token(granted): + assert token_has_scope(granted, ANY_SCOPE) is True From 4985c3a49fb68b0232c1addf1eb549a0b2852e24 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:11:13 -0400 Subject: [PATCH 06/43] feat: authenticate API requests with scoped bearer tokens Wires bearer-token auth into the single get_current_user dependency all ~55 authenticated routes already use, so token auth covers the whole API without touching any route. Cookie/origin enforcement is untouched and skipped only when a bearer token is present. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/auth.py | 72 +++++++++- fileglancer/server.py | 14 +- tests/test_api_token_auth.py | 258 +++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 7 deletions(-) create mode 100644 tests/test_api_token_auth.py diff --git a/fileglancer/auth.py b/fileglancer/auth.py index f3ad3240d..edffa38df 100644 --- a/fileglancer/auth.py +++ b/fileglancer/auth.py @@ -3,6 +3,7 @@ """ import os import hashlib +import hmac from datetime import datetime, timedelta, UTC from typing import Optional from urllib.parse import urlsplit @@ -163,9 +164,17 @@ def get_current_user(request: Request, settings: Settings) -> str: """ Get the current authenticated user - Always validates session from cookie (for both OKTA and simple auth) + Resolves an Authorization: Bearer API token when one is present, and + otherwise validates the session cookie (for both OKTA and simple auth). + Because every authenticated route depends on this one function, adding + token auth here covers the whole API without touching any route. + Raises HTTPException(401) if authentication fails """ + raw_token = parse_bearer_token(request) + if raw_token: + return get_user_from_token(request, settings, raw_token) + user_session = get_session_from_cookie(request, settings) if not user_session: @@ -286,3 +295,64 @@ def token_has_scope(granted: str, required: str) -> bool: # ':write' implies ':read' for the same resource. resource, _, action = required.partition(":") return action == "read" and f"{resource}:write" in held + + +def parse_bearer_token(request: Request) -> Optional[str]: + """Extract a Fileglancer API token from the Authorization header. + + Returns None when the header is absent, is not a Bearer credential, or + carries a Bearer value that is not one of our tokens. In all three cases + the caller falls through to cookie auth rather than failing, so an + unrelated Bearer header does not break a cookie-authenticated request. + """ + header = request.headers.get('authorization', '') + scheme, _, value = header.partition(' ') + value = value.strip() + if scheme.lower() != 'bearer' or not value.startswith(db.API_TOKEN_PREFIX + '_'): + return None + return value + + +def get_user_from_token(request: Request, settings: Settings, raw_token: str) -> str: + """Resolve an API token to a username, enforcing expiry and scope. + + Raises HTTPException(401) for an invalid or expired token and + HTTPException(403) when the token lacks the scope the request needs. + """ + parts = raw_token.split('_', 2) + if len(parts) != 3 or not parts[1] or not parts[2]: + raise HTTPException(status_code=401, detail="Malformed API token") + _, token_id, secret = parts + + with db.get_db_session(settings.db_url) as session: + row = db.get_api_token_by_id(session, token_id) + # Hash the presented secret regardless, then compare in constant time. + presented = db.hash_token_secret(secret) + if row is None or not hmac.compare_digest(presented, row.token_hash): + logger.info(f"Rejected API token with id {token_id}") + raise HTTPException(status_code=401, detail="Invalid API token") + + # SQLAlchemy strips tzinfo on read; add it back before comparing. + expires_at = row.expires_at.replace(tzinfo=UTC) + if expires_at < datetime.now(UTC): + raise HTTPException( + status_code=401, + detail=f"API token expired on {expires_at.date().isoformat()}") + + username = row.username + granted = row.scopes + + required = required_scope(request.url.path, request.method) + if required is None: + raise HTTPException( + status_code=403, + detail=f"{request.url.path} is not accessible with an API token") + if not token_has_scope(granted, required): + raise HTTPException( + status_code=403, + detail=f"API token is missing the required scope: {required}") + + with db.get_db_session(settings.db_url) as session: + db.touch_api_token(session, token_id) + + return username diff --git a/fileglancer/server.py b/fileglancer/server.py index daf66ab15..17c7f8583 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -174,15 +174,17 @@ def get_current_user(request: Request): """ FastAPI dependency to get the current authenticated user - If OKTA auth is enabled, validates session from cookie - If OKTA auth is disabled, falls back to $USER environment variable + Resolves either an Authorization: Bearer API token or a session cookie. - Also enforces the cross-origin allowlist: a request carrying an Origin - header that is neither same-origin nor listed in api_allowed_origins is - rejected with 403 before the session is even consulted. + The cross-origin allowlist is enforced for cookie auth only. Origin + enforcement exists because the session cookie is ambient — a browser + attaches it to same-site requests whether or not the page meant to make + them. A bearer token is never ambient, so there is no CSRF surface to + defend, and programmatic clients send no Origin header at all. """ settings = get_settings() - auth.enforce_request_origin(request, settings) + if auth.parse_bearer_token(request) is None: + auth.enforce_request_origin(request, settings) return auth.get_current_user(request, settings) diff --git a/tests/test_api_token_auth.py b/tests/test_api_token_auth.py new file mode 100644 index 000000000..e6b6838f2 --- /dev/null +++ b/tests/test_api_token_auth.py @@ -0,0 +1,258 @@ +"""End-to-end tests for bearer-token authentication and scope enforcement. + +Uses the real get_current_user dependency (no override), so these exercise the +full auth path the way a Python client would hit it. +""" +import os +import shutil +import tempfile +from datetime import datetime, timedelta, UTC + +import pytest +from fastapi.testclient import TestClient + +import fileglancer.database +import fileglancer.settings +from fileglancer.database import ( + Base, + FileSharePathDB, + create_api_token, + create_engine, + dispose_engine, + sessionmaker, +) +from fileglancer.server import create_app +from fileglancer.settings import Settings + + +@pytest.fixture +def token_app(): + """App backed by a temp database with one file share, no auth override.""" + temp_dir = tempfile.mkdtemp() + db_url = f"sqlite:///{os.path.join(temp_dir, 'test.db')}" + engine = create_engine(db_url) + Base.metadata.create_all(engine) + db_session = sessionmaker(bind=engine)() + db_session.add(FileSharePathDB( + name="tempdir", zone="testzone", group="testgroup", storage="local", + mount_path=temp_dir, mac_path="smb://tempdir/test/path", + windows_path="\\\\tempdir\\test\\path", linux_path="/tempdir/test/path", + )) + db_session.commit() + + settings = Settings(db_url=db_url, file_share_mounts=[], cli_mode=True) + # get_current_user resolves settings via get_settings() at request time. + # server.py binds that name at import, so patch it there too (not just in + # the settings/database modules) — otherwise the real endpoint would look + # up the token in the default database instead of this test's. + import fileglancer.server + original = fileglancer.settings.get_settings + fileglancer.settings.get_settings = lambda: settings + fileglancer.database.get_settings = lambda: settings + fileglancer.server.get_settings = lambda: settings + + yield create_app(settings), db_session + + db_session.close() + engine.dispose() + dispose_engine(db_url) + from fileglancer.user_worker import _filestore_cache, _user_groups_cache + _filestore_cache.clear() + _user_groups_cache.clear() + fileglancer.settings.get_settings = original + fileglancer.database.get_settings = original + fileglancer.server.get_settings = original + shutil.rmtree(temp_dir) + + +def _client_with_token(token_app, scopes, expires_in_days=30): + app, db_session = token_app + row, plaintext = create_api_token(db_session, "alice", "test", scopes, + expires_in_days=expires_in_days) + client = TestClient(app) + client.headers["Authorization"] = f"Bearer {plaintext}" + return client, row + + +def test_valid_token_authenticates_a_scoped_request(token_app): + client, _ = _client_with_token(token_app, ["files:read"]) + + response = client.get("/api/files/tempdir") + + assert response.status_code == 200 + + +def test_write_scope_satisfies_a_read_request(token_app): + client, _ = _client_with_token(token_app, ["files:write"]) + + assert client.get("/api/files/tempdir").status_code == 200 + + +def test_read_scope_is_refused_a_write_request(token_app): + client, _ = _client_with_token(token_app, ["files:read"]) + + response = client.post("/api/files/tempdir?subpath=newdir", + json={"type": "directory"}) + + assert response.status_code == 403 + assert "files:write" in response.json()["error"] + + +def test_wrong_resource_scope_is_refused(token_app): + client, _ = _client_with_token(token_app, ["links:read"]) + + response = client.get("/api/files/tempdir") + + assert response.status_code == 403 + + +def test_session_only_path_is_refused_to_every_token(token_app): + client, _ = _client_with_token(token_app, ["files:write", "links:write", + "jobs:write"]) + + response = client.get("/api/ssh-keys") + + assert response.status_code == 403 + assert "not accessible with an API token" in response.json()["error"] + + +@pytest.mark.xfail(reason="POST /api/tokens lands in Task 4", strict=True) +def test_a_token_cannot_mint_another_token(token_app): + client, _ = _client_with_token(token_app, ["files:write", "links:write", + "jobs:write"]) + + response = client.post("/api/tokens", json={ + "name": "escalated", "scopes": ["files:write"], "expires_in_days": 30, + }) + + assert response.status_code == 403 + + +def test_profile_is_readable_with_any_scope(token_app): + client, _ = _client_with_token(token_app, ["links:read"]) + + assert client.get("/api/profile").status_code == 200 + + +def test_expired_token_is_rejected(token_app): + app, db_session = token_app + row, plaintext = create_api_token(db_session, "alice", "old", ["files:read"]) + row.expires_at = datetime.now(UTC) - timedelta(days=1) + db_session.commit() + + client = TestClient(app) + response = client.get("/api/files/tempdir", + headers={"Authorization": f"Bearer {plaintext}"}) + + assert response.status_code == 401 + assert "expired" in response.json()["error"].lower() + + +def test_unknown_token_id_is_rejected(token_app): + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer fgt_notarealid_secret"}) + + assert response.status_code == 401 + + +def test_wrong_secret_for_a_real_token_id_is_rejected(token_app): + app, db_session = token_app + row, _ = create_api_token(db_session, "alice", "test", ["files:read"]) + client = TestClient(app) + + response = client.get( + "/api/files/tempdir", + headers={"Authorization": f"Bearer fgt_{row.token_id}_wrongsecret"}) + + assert response.status_code == 401 + + +def test_malformed_token_is_rejected(token_app): + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer fgt_missingsecret"}) + + assert response.status_code == 401 + assert "malformed" in response.json()["error"].lower() + + +def test_non_fgt_bearer_falls_through_to_cookie_auth(token_app): + # A bearer value that is not an fgt_ token is not a Fileglancer API token, + # so the request is treated as unauthenticated rather than rejected as a + # bad token. + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer someothertoken"}) + + assert response.status_code == 401 + assert "log in" in response.json()["error"].lower() + + +def test_token_auth_ignores_a_disallowed_origin(token_app): + # Origin enforcement exists to protect ambient cookie auth. A bearer token + # is not ambient, so a cross-origin script with a valid token is fine. + client, _ = _client_with_token(token_app, ["files:read"]) + + response = client.get("/api/files/tempdir", + headers={"Origin": "https://evil.example.com"}) + + assert response.status_code == 200 + + +def test_token_use_updates_last_used_at(token_app): + client, row = _client_with_token(token_app, ["files:read"]) + assert row.last_used_at is None + + client.get("/api/files/tempdir") + + _, db_session = token_app + db_session.refresh(row) + assert row.last_used_at is not None + + +def test_every_authenticated_route_is_classified(token_app): + """Guard against drift: a new /api route must be deliberately placed. + + Every route that depends on get_current_user must either be reachable via + the scope table or sit under a known session-only prefix. Adding a route + without updating one of the two lists fails here. + """ + from fileglancer.auth import required_scope + from fileglancer.server import get_current_user + + SESSION_ONLY_PREFIXES = ( + "/api/ticket", + "/api/preference", + "/api/ssh-keys", + "/api/apps", + "/api/catalog", + "/api/tokens", + ) + + app, _ = token_app + unclassified = [] + for route in app.routes: + path = getattr(route, "path", "") + dependant = getattr(route, "dependant", None) + if not path.startswith("/api/") or dependant is None: + continue + if not any(d.call is get_current_user for d in dependant.dependencies): + continue + if path.startswith(SESSION_ONLY_PREFIXES): + continue + for method in getattr(route, "methods", set()): + if required_scope(path, method) is None: + unclassified.append(f"{method} {path}") + + assert not unclassified, ( + "These authenticated routes are neither scoped nor session-only. Add " + "them to _SCOPE_PREFIXES in auth.py or to SESSION_ONLY_PREFIXES here: " + f"{sorted(unclassified)}" + ) From 49082d279535d1fd5023e84af896a3cb8335413f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:22:57 -0400 Subject: [PATCH 07/43] feat: add /api/tokens management endpoints Adds ApiTokenInfo/ApiTokenListResponse/ApiTokenCreateRequest/ ApiTokenCreateResponse models and GET/POST/DELETE /api/tokens routes so users can create, list, and revoke API tokens. These endpoints are session-only by omission from the auth.py scope table, so a token can never be used to mint or revoke another token. Also un-xfails test_a_token_cannot_mint_another_token from Task 3 (now a real 403) and adds three regression tests for previously-uncovered branches: empty-id and empty-secret malformed token parsing, and a pinned property that a bearer token never falls back to cookie auth. --- fileglancer/model.py | 56 ++++++++++++++++++++ fileglancer/server.py | 62 ++++++++++++++++++++++ tests/test_api_token_auth.py | 50 +++++++++++++++++- tests/test_api_token_endpoints.py | 85 +++++++++++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 tests/test_api_token_endpoints.py diff --git a/fileglancer/model.py b/fileglancer/model.py index c7fa8b690..fc10a19b5 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -318,6 +318,62 @@ class NeuroglancerShortLinkResponse(BaseModel): ) +# --- API token models --- + +class ApiTokenInfo(BaseModel): + """An API token, without its secret""" + token_id: str = Field( + description="Public identifier for the token, used to revoke it" + ) + name: str = Field( + description="User-supplied label for the token" + ) + scopes: List[str] = Field( + description="Scopes granted to this token" + ) + created_at: datetime = Field( + description="When this token was created" + ) + expires_at: datetime = Field( + description="When this token stops working" + ) + last_used_at: Optional[datetime] = Field( + description="When this token was last used, accurate to five minutes", + default=None + ) + + +class ApiTokenListResponse(BaseModel): + tokens: List[ApiTokenInfo] = Field( + description="The caller's API tokens, newest first" + ) + + +class ApiTokenCreateRequest(BaseModel): + """Request payload for creating an API token""" + name: str = Field( + description="A label to identify this token later", + min_length=1, max_length=100 + ) + scopes: List[str] = Field( + description="Scopes to grant, e.g. ['files:read', 'links:write']" + ) + expires_in_days: int = Field( + description="How long the token stays valid", + default=30, ge=1, le=365 + ) + + +class ApiTokenCreateResponse(BaseModel): + """Response for a newly created API token""" + token: ApiTokenInfo = Field( + description="The token metadata" + ) + secret: str = Field( + description="The full token string. Returned once and never recoverable." + ) + + # --- App Manifest Models --- # Conservative CLI-flag shape: one or two leading dashes, then an alphanumeric diff --git a/fileglancer/server.py b/fileglancer/server.py index 17c7f8583..3aa71cca5 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -267,6 +267,22 @@ def _convert_ticket(db_ticket: db.TicketDB) -> Ticket: ) +def _convert_api_token(row: db.ApiTokenDB) -> ApiTokenInfo: + """Convert an ApiTokenDB row to the public ApiTokenInfo model. + + Deliberately does not carry token_hash: this model is what the listing + endpoint returns. + """ + return ApiTokenInfo( + token_id=row.token_id, + name=row.name, + scopes=row.scopes.split(), + created_at=row.created_at, + expires_at=row.expires_at, + last_used_at=row.last_used_at, + ) + + def _validate_filename(name: str) -> None: """ Validate that a filename/dirname is safe and only refers to a single item in the current directory. @@ -1447,6 +1463,52 @@ async def generate_temp_ssh_key( headers=headers, ) + # API token management. These endpoints are session-only: they are absent + # from the scope table in auth.py, so deny-by-default means a token can + # never be used to mint or revoke another token. + @app.get("/api/tokens", response_model=ApiTokenListResponse, + description="List the current user's API tokens") + async def list_api_tokens(username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + rows = db.list_api_tokens(session, username) + return ApiTokenListResponse( + tokens=[_convert_api_token(row) for row in rows]) + + + @app.post("/api/tokens", response_model=ApiTokenCreateResponse, + status_code=201, + description="Create an API token; the secret is returned once") + async def create_api_token(payload: ApiTokenCreateRequest, + username: str = Depends(get_current_user)): + if not payload.scopes: + raise HTTPException(status_code=400, + detail="At least one scope is required") + unknown = sorted(set(payload.scopes) - auth.API_SCOPES) + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown scopes: {', '.join(unknown)}. " + f"Valid scopes: {', '.join(sorted(auth.API_SCOPES))}") + + with db.get_db_session(settings.db_url) as session: + row, secret = db.create_api_token( + session, username, payload.name.strip(), payload.scopes, + expires_in_days=payload.expires_in_days) + logger.info(f"Created API token {row.token_id} for {username} " + f"with scopes {row.scopes}") + return ApiTokenCreateResponse(token=_convert_api_token(row), + secret=secret) + + + @app.delete("/api/tokens/{token_id}", description="Revoke an API token") + async def delete_api_token(token_id: str = Path(..., description="The token's public id"), + username: str = Depends(get_current_user)): + with db.get_db_session(settings.db_url) as session: + if db.delete_api_token(session, username, token_id) == 0: + raise HTTPException(status_code=404, detail="Token not found") + logger.info(f"Revoked API token {token_id} for {username}") + return {"message": f"Token {token_id} revoked"} + # File content endpoint @app.head("/api/content/{path_name:path}") async def head_file_content(path_name: str, diff --git a/tests/test_api_token_auth.py b/tests/test_api_token_auth.py index e6b6838f2..62d7c140d 100644 --- a/tests/test_api_token_auth.py +++ b/tests/test_api_token_auth.py @@ -18,6 +18,7 @@ FileSharePathDB, create_api_token, create_engine, + create_session, dispose_engine, sessionmaker, ) @@ -116,7 +117,6 @@ def test_session_only_path_is_refused_to_every_token(token_app): assert "not accessible with an API token" in response.json()["error"] -@pytest.mark.xfail(reason="POST /api/tokens lands in Task 4", strict=True) def test_a_token_cannot_mint_another_token(token_app): client, _ = _client_with_token(token_app, ["files:write", "links:write", "jobs:write"]) @@ -217,6 +217,54 @@ def test_token_use_updates_last_used_at(token_app): assert row.last_used_at is not None +def test_malformed_token_with_empty_id_is_rejected(token_app): + app, _ = token_app + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": "Bearer fgt__secretonly"}) + + assert response.status_code == 401 + assert "malformed" in response.json()["error"].lower() + + +def test_malformed_token_with_empty_secret_is_rejected(token_app): + app, db_session = token_app + row, _ = create_api_token(db_session, "alice", "test", ["files:read"]) + client = TestClient(app) + + response = client.get("/api/files/tempdir", + headers={"Authorization": f"Bearer fgt_{row.token_id}_"}) + + assert response.status_code == 401 + assert "malformed" in response.json()["error"].lower() + + +def test_bearer_token_never_falls_back_to_cookie_auth(token_app): + """A request carrying BOTH a bearer token and a session cookie must be + authenticated by the token alone, never by the cookie. + + server.py skips the Origin check whenever a bearer token is present. If + get_current_user ever fell back to the cookie when the token failed, that + combination would authenticate a cross-origin cookie request with the + Origin check skipped -- a CSRF hole. This pins the property shut. + """ + app, db_session = token_app + user_session = create_session( + db_session, username="alice", email=None, + expires_at=datetime.now(UTC) + timedelta(hours=1), + session_secret_key="testkey") + client = TestClient(app) + client.cookies.set("fg_session", user_session.session_id) + + response = client.get( + "/api/files/tempdir", + headers={"Authorization": "Bearer fgt_deadbeefcafe_wrongsecret", + "Origin": "https://evil.example.com"}) + + assert response.status_code == 401 + + def test_every_authenticated_route_is_classified(token_app): """Guard against drift: a new /api route must be deliberately placed. diff --git a/tests/test_api_token_endpoints.py b/tests/test_api_token_endpoints.py new file mode 100644 index 000000000..c4483b6ca --- /dev/null +++ b/tests/test_api_token_endpoints.py @@ -0,0 +1,85 @@ +"""Tests for the /api/tokens management endpoints. + +These use the cookie-auth override from test_endpoints, since token +management is deliberately session-only. +""" +import pytest + +from test_endpoints import TEST_USERNAME, temp_dir, test_app, test_client # noqa: F401 + + +def test_list_is_empty_for_a_new_user(test_client): + response = test_client.get("/api/tokens") + + assert response.status_code == 200 + assert response.json() == {"tokens": []} + + +def test_create_returns_the_secret_exactly_once(test_client): + response = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], "expires_in_days": 30, + }) + + assert response.status_code == 201 + body = response.json() + assert body["secret"].startswith("fgt_") + assert body["token"]["name"] == "laptop" + assert body["token"]["scopes"] == ["files:read"] + + # The secret never appears again in the listing. + listing = test_client.get("/api/tokens").json() + assert len(listing["tokens"]) == 1 + assert "secret" not in listing["tokens"][0] + assert "token_hash" not in listing["tokens"][0] + + +def test_create_defaults_to_thirty_days(test_client): + response = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], + }) + + assert response.status_code == 201 + + +def test_create_rejects_unknown_scope(test_client): + response = test_client.post("/api/tokens", json={ + "name": "bad", "scopes": ["files:read", "secrets:steal"], + }) + + assert response.status_code == 400 + assert "secrets:steal" in response.json()["error"] + + +def test_create_rejects_empty_scope_list(test_client): + response = test_client.post("/api/tokens", json={"name": "bad", "scopes": []}) + + assert response.status_code == 400 + + +def test_create_rejects_expiry_over_the_maximum(test_client): + response = test_client.post("/api/tokens", json={ + "name": "forever", "scopes": ["files:read"], "expires_in_days": 366, + }) + + assert response.status_code == 400 + + +def test_create_rejects_blank_name(test_client): + response = test_client.post("/api/tokens", json={ + "name": "", "scopes": ["files:read"], + }) + + assert response.status_code == 400 + + +def test_delete_revokes_the_token(test_client): + token_id = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], + }).json()["token"]["token_id"] + + assert test_client.delete(f"/api/tokens/{token_id}").status_code == 200 + assert test_client.get("/api/tokens").json() == {"tokens": []} + + +def test_delete_unknown_token_returns_404(test_client): + assert test_client.delete("/api/tokens/doesnotexist").status_code == 404 From 00848bc8ea9b308dae80cb06c8cac178c60e118e Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:29:10 -0400 Subject: [PATCH 08/43] fix: reject whitespace-only token names, dedupe scopes on create Task 4 review fix round 1: ApiTokenCreateRequest now strips and rejects a whitespace-only name via a field_validator (previously length validation ran before the route's strip, letting " " through as a stored blank name). create_api_token now deduplicates scopes before storing them, so ["files:read", "files:read"] no longer stores as a repeated string. --- fileglancer/database.py | 2 +- fileglancer/model.py | 10 ++++++++++ fileglancer/server.py | 2 +- tests/test_api_token_endpoints.py | 17 +++++++++++++++++ tests/test_api_tokens.py | 7 +++++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/fileglancer/database.py b/fileglancer/database.py index 4ec160162..1f1403704 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -1058,7 +1058,7 @@ def create_api_token(session: Session, username: str, name: str, token_hash=hash_token_secret(secret), username=username, name=name, - scopes=" ".join(sorted(scopes)), + scopes=" ".join(sorted(set(scopes))), created_at=now, expires_at=now + timedelta(days=expires_in_days), last_used_at=None, diff --git a/fileglancer/model.py b/fileglancer/model.py index fc10a19b5..414ed656d 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -363,6 +363,16 @@ class ApiTokenCreateRequest(BaseModel): default=30, ge=1, le=365 ) + @field_validator('name') + @classmethod + def _strip_name(cls, value: str) -> str: + """Strip before validating, so a whitespace-only name is rejected + rather than silently stored as an empty string.""" + stripped = value.strip() + if not stripped: + raise ValueError("name must not be blank") + return stripped + class ApiTokenCreateResponse(BaseModel): """Response for a newly created API token""" diff --git a/fileglancer/server.py b/fileglancer/server.py index 3aa71cca5..373edb2f3 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -1492,7 +1492,7 @@ async def create_api_token(payload: ApiTokenCreateRequest, with db.get_db_session(settings.db_url) as session: row, secret = db.create_api_token( - session, username, payload.name.strip(), payload.scopes, + session, username, payload.name, payload.scopes, expires_in_days=payload.expires_in_days) logger.info(f"Created API token {row.token_id} for {username} " f"with scopes {row.scopes}") diff --git a/tests/test_api_token_endpoints.py b/tests/test_api_token_endpoints.py index c4483b6ca..46c981d42 100644 --- a/tests/test_api_token_endpoints.py +++ b/tests/test_api_token_endpoints.py @@ -83,3 +83,20 @@ def test_delete_revokes_the_token(test_client): def test_delete_unknown_token_returns_404(test_client): assert test_client.delete("/api/tokens/doesnotexist").status_code == 404 + + +def test_create_rejects_whitespace_only_name(test_client): + response = test_client.post("/api/tokens", json={ + "name": " ", "scopes": ["files:read"], + }) + + assert response.status_code == 400 + + +def test_create_strips_surrounding_whitespace_from_name(test_client): + response = test_client.post("/api/tokens", json={ + "name": " laptop ", "scopes": ["files:read"], + }) + + assert response.status_code == 201 + assert response.json()["token"]["name"] == "laptop" diff --git a/tests/test_api_tokens.py b/tests/test_api_tokens.py index 7cc9ed266..37933b6e4 100644 --- a/tests/test_api_tokens.py +++ b/tests/test_api_tokens.py @@ -66,6 +66,13 @@ def test_scopes_are_stored_space_separated_and_sorted(db_session): assert row.scopes == "files:read links:write" +def test_duplicate_scopes_are_deduplicated(db_session): + row, _ = create_api_token(db_session, "alice", "laptop", + ["files:read", "files:read", "links:write"]) + + assert row.scopes == "files:read links:write" + + def test_token_ids_are_unique_across_creations(db_session): ids = {create_api_token(db_session, "alice", f"t{i}", ["files:read"])[0].token_id for i in range(50)} From e916bca7e825b4ea12f064431c46eecaef33b2c0 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:34:22 -0400 Subject: [PATCH 09/43] feat: add Python client with absolute path resolution Adds fileglancer.Fileglancer, the core of the Python client for Tasks 6-8 to build file/link/job methods on top of: construction from FILEGLANCER_URL/FILEGLANCER_TOKEN or explicit args, HTTP plumbing via httpx with server error bodies surfaced as FileglancerError, and client-side absolute-path <-> (file share, relative path) resolution mirroring resolvePathToFsp in pathHandling.ts. --- fileglancer/__init__.py | 4 + fileglancer/client.py | 152 +++++++++++++++++++++++++++++++++++++ tests/test_client_paths.py | 146 +++++++++++++++++++++++++++++++++++ 3 files changed, 302 insertions(+) create mode 100644 fileglancer/client.py create mode 100644 tests/test_client_paths.py diff --git a/fileglancer/__init__.py b/fileglancer/__init__.py index e69de29bb..5ce956d47 100644 --- a/fileglancer/__init__.py +++ b/fileglancer/__init__.py @@ -0,0 +1,4 @@ +"""Fileglancer: NGFF browsing and sharing platform.""" +from fileglancer.client import NEUROGLANCER_URL, Fileglancer, FileglancerError + +__all__ = ["Fileglancer", "FileglancerError", "NEUROGLANCER_URL"] diff --git a/fileglancer/client.py b/fileglancer/client.py new file mode 100644 index 000000000..048e2c6b9 --- /dev/null +++ b/fileglancer/client.py @@ -0,0 +1,152 @@ +"""Python client for the Fileglancer HTTP API. + +Authenticates with an API token created in the Fileglancer web UI, and +operates on absolute filesystem paths rather than Fileglancer's internal +(file share, relative path) pair. + + from fileglancer import Fileglancer + + fg = Fileglancer() # reads FILEGLANCER_URL and FILEGLANCER_TOKEN + link = fg.create_data_link("/nearline/alice/sample.zarr") +""" +import os +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from fileglancer.model import FileSharePath + +# Neuroglancer base URL used when the caller does not supply one. The server +# has no configured default and POST /api/neuroglancer/nglinks requires +# url_base when given a state directly, so the default lives here. +NEUROGLANCER_URL = "https://neuroglancer-demo.appspot.com" + + +class FileglancerError(Exception): + """Raised for API errors and for paths that match no file share.""" + + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code + + +class Fileglancer: + """A client for the Fileglancer HTTP API. + + Args: + url: Fileglancer server URL. Defaults to $FILEGLANCER_URL. + token: An API token created in the web UI. Defaults to + $FILEGLANCER_TOKEN. + timeout: Per-request timeout in seconds. + transport: An httpx transport, for testing against an ASGI app. + """ + + def __init__(self, url: Optional[str] = None, token: Optional[str] = None, + timeout: float = 60.0, + transport: Optional[httpx.BaseTransport] = None): + url = url or os.environ.get("FILEGLANCER_URL") + token = token or os.environ.get("FILEGLANCER_TOKEN") + if not url: + raise FileglancerError( + "No Fileglancer server URL. Pass url= or set FILEGLANCER_URL.") + if not token: + raise FileglancerError( + "No API token. Pass token= or set FILEGLANCER_TOKEN. Create a " + "token on the API Tokens page of the Fileglancer web UI.") + + self._client = httpx.Client( + base_url=url.rstrip("/"), + headers={"Authorization": f"Bearer {token}"}, + timeout=timeout, + transport=transport, + follow_redirects=True, + ) + self._fsp_cache: Optional[List[FileSharePath]] = None + + def close(self) -> None: + """Close the underlying HTTP connection pool.""" + self._client.close() + + def __enter__(self) -> "Fileglancer": + return self + + def __exit__(self, *exc_info) -> None: + self.close() + + # --- HTTP plumbing --- + + def _request(self, method: str, path: str, **kwargs) -> httpx.Response: + """Make a request, turning any error response into FileglancerError.""" + response = self._client.request(method, path, **kwargs) + if response.status_code >= 400: + try: + body = response.json() + # The server's exception handlers rewrite every error body to + # {"error": ...}; 'detail' is accepted as a fallback in case a + # response ever bypasses those handlers. + detail = body.get("error") or body.get("detail") or response.text + except ValueError: + detail = response.text + raise FileglancerError(f"{method} {path} failed: {detail}", + status_code=response.status_code) + return response + + # --- File shares and path resolution --- + + def file_share_paths(self) -> List[FileSharePath]: + """List the server's file shares. Cached; call refresh() to re-fetch.""" + if self._fsp_cache is None: + data = self._request("GET", "/api/file-share-paths").json() + self._fsp_cache = [FileSharePath(**p) for p in data["paths"]] + return self._fsp_cache + + def refresh(self) -> None: + """Drop the cached file share list.""" + self._fsp_cache = None + + def _resolve(self, path: str) -> Tuple[str, str]: + """Resolve an absolute path to (file share name, relative path). + + Mirrors resolvePathToFsp in frontend/src/utils/pathHandling.ts: the + longest matching prefix across every mount form wins, and the + remainder must be empty or start with '/'. That last condition is what + keeps '/misc/public' from swallowing '/misc/public-archive'. + + Accepts Linux, Mac (smb://) and Windows (UNC) mount forms, so a path + pasted from any client platform resolves. + """ + normalized = path.strip().replace("\\", "/") + + best_fsp: Optional[FileSharePath] = None + best_prefix = "" + for fsp in self.file_share_paths(): + candidates = (fsp.mount_path, fsp.linux_path, fsp.mac_path, + fsp.windows_path) + for candidate in candidates: + if not candidate: + continue + candidate = candidate.replace("\\", "/").rstrip("/") + if len(candidate) <= len(best_prefix): + continue + if not normalized.startswith(candidate): + continue + remainder = normalized[len(candidate):] + if remainder and not remainder.startswith("/"): + continue + best_fsp, best_prefix = fsp, candidate + + if best_fsp is None: + mounts = ", ".join(sorted(f.mount_path + for f in self.file_share_paths())) + raise FileglancerError( + f"No file share matches {path!r}. Available mount points: {mounts}") + + return best_fsp.name, normalized[len(best_prefix):].strip("/") + + def abspath(self, fsp_name: str, path: str = "") -> str: + """Build an absolute path from a file share name and relative path.""" + for fsp in self.file_share_paths(): + if fsp.name == fsp_name: + root = fsp.mount_path.rstrip("/") + return f"{root}/{path}" if path else root + raise FileglancerError(f"Unknown file share: {fsp_name}") diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py new file mode 100644 index 000000000..ec6d508bd --- /dev/null +++ b/tests/test_client_paths.py @@ -0,0 +1,146 @@ +"""Tests for client construction and absolute-path resolution. + +Resolution mirrors resolvePathToFsp in frontend/src/utils/pathHandling.ts. +The FSP fixture below is the shared fixture set both resolvers are checked +against; keep it in sync with the TypeScript test. +""" +import httpx +import pytest + +from fileglancer.client import Fileglancer, FileglancerError +from fileglancer.model import FileSharePath + + +# Shared fixture set. The '/misc/public' vs '/misc/public-archive' pair is the +# prefix-boundary case; 'home' exercises an FSP with no alternate mount forms. +SHARED_FSPS = [ + FileSharePath(name="nearline", zone="z", mount_path="/nearline", + linux_path="/nearline", mac_path="smb://store/nearline", + windows_path="\\\\store\\nearline"), + FileSharePath(name="public", zone="z", mount_path="/misc/public", + linux_path="/misc/public"), + FileSharePath(name="public-archive", zone="z", + mount_path="/misc/public-archive", + linux_path="/misc/public-archive"), + FileSharePath(name="home", zone="z", mount_path="/home/alice"), +] + + +@pytest.fixture +def fg(): + """A client with the FSP list pre-seeded, so no HTTP call is made.""" + client = Fileglancer(url="http://testserver", token="fgt_abc_def") + client._fsp_cache = SHARED_FSPS + return client + + +def test_requires_a_url(monkeypatch): + monkeypatch.delenv("FILEGLANCER_URL", raising=False) + monkeypatch.setenv("FILEGLANCER_TOKEN", "fgt_abc_def") + + with pytest.raises(FileglancerError, match="FILEGLANCER_URL"): + Fileglancer() + + +def test_requires_a_token(monkeypatch): + monkeypatch.setenv("FILEGLANCER_URL", "http://testserver") + monkeypatch.delenv("FILEGLANCER_TOKEN", raising=False) + + with pytest.raises(FileglancerError, match="FILEGLANCER_TOKEN"): + Fileglancer() + + +def test_reads_url_and_token_from_the_environment(monkeypatch): + monkeypatch.setenv("FILEGLANCER_URL", "http://testserver/") + monkeypatch.setenv("FILEGLANCER_TOKEN", "fgt_abc_def") + + client = Fileglancer() + + assert str(client._client.base_url) == "http://testserver" + assert client._client.headers["authorization"] == "Bearer fgt_abc_def" + + +def test_explicit_arguments_win_over_the_environment(monkeypatch): + monkeypatch.setenv("FILEGLANCER_URL", "http://from-env") + monkeypatch.setenv("FILEGLANCER_TOKEN", "fgt_env_env") + + client = Fileglancer(url="http://explicit", token="fgt_x_y") + + assert str(client._client.base_url) == "http://explicit" + assert client._client.headers["authorization"] == "Bearer fgt_x_y" + + +@pytest.mark.parametrize("path,expected", [ + ("/nearline/alice/sample.zarr", ("nearline", "alice/sample.zarr")), + ("/nearline", ("nearline", "")), + ("/nearline/", ("nearline", "")), + ("/home/alice/data", ("home", "data")), + ("smb://store/nearline/alice", ("nearline", "alice")), + ("\\\\store\\nearline\\alice", ("nearline", "alice")), + (" /nearline/alice ", ("nearline", "alice")), +]) +def test_resolves_every_mount_form(fg, path, expected): + assert fg._resolve(path) == expected + + +def test_longest_prefix_wins_at_a_shared_boundary(fg): + # '/misc/public-archive/x' must not resolve to the 'public' share. + assert fg._resolve("/misc/public-archive/x") == ("public-archive", "x") + assert fg._resolve("/misc/public/x") == ("public", "x") + + +def test_partial_segment_does_not_match(fg): + # '/nearlinex' shares a string prefix with '/nearline' but is a different + # directory, so it must not resolve. + with pytest.raises(FileglancerError): + fg._resolve("/nearlinex/data") + + +def test_unmatched_path_error_lists_available_mounts(fg): + with pytest.raises(FileglancerError) as excinfo: + fg._resolve("/not/a/share/at/all") + + message = str(excinfo.value) + assert "/not/a/share/at/all" in message + assert "/nearline" in message + assert "/home/alice" in message + + +def test_abspath_round_trips_resolve(fg): + fsp_name, relative = fg._resolve("/nearline/alice/sample.zarr") + + assert fg.abspath(fsp_name, relative) == "/nearline/alice/sample.zarr" + + +def test_abspath_of_the_share_root(fg): + assert fg.abspath("nearline") == "/nearline" + + +def test_abspath_rejects_an_unknown_share(fg): + with pytest.raises(FileglancerError, match="Unknown file share"): + fg.abspath("nosuchshare", "x") + + +def test_refresh_drops_the_cache(fg): + assert fg._fsp_cache is not None + + fg.refresh() + + assert fg._fsp_cache is None + + +def test_error_message_uses_the_servers_error_key(fg): + """The server rewrites every error body to {"error": ...}, so the message + must be extracted from that key rather than falling back to raw JSON.""" + def handler(request): + return httpx.Response(404, json={"error": "Token not found"}) + + fg._client = httpx.Client(base_url="http://testserver", + transport=httpx.MockTransport(handler)) + + with pytest.raises(FileglancerError) as excinfo: + fg._request("GET", "/api/tokens/nope") + + assert "Token not found" in str(excinfo.value) + assert "{" not in str(excinfo.value) + assert excinfo.value.status_code == 404 From 635edd7872ce1afc61a418df4d5c067843805343 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:47:19 -0400 Subject: [PATCH 10/43] feat: add file operations to the Python client Adds ls, stat, mkdir, rename, delete, read, write to Fileglancer, each taking an absolute filesystem path and resolving it via _resolve() before issuing the corresponding /api/files or /api/content request. Also fixes Fileglancer's constructor to wrap async-only transports (httpx.ASGITransport) in a small sync adapter, since httpx.Client is synchronous and ASGITransport only implements the async transport interface. This was required for the new tests, which drive the real app over ASGITransport with no server process. --- fileglancer/client.py | 100 ++++++++++++++++++++++++++++ tests/test_client_files.py | 130 +++++++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+) create mode 100644 tests/test_client_files.py diff --git a/fileglancer/client.py b/fileglancer/client.py index 048e2c6b9..eb841e2f7 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -9,11 +9,13 @@ fg = Fileglancer() # reads FILEGLANCER_URL and FILEGLANCER_TOKEN link = fg.create_data_link("/nearline/alice/sample.zarr") """ +import asyncio import os from typing import Any, Dict, List, Optional, Tuple import httpx +from fileglancer.filestore import FileInfo from fileglancer.model import FileSharePath # Neuroglancer base URL used when the caller does not supply one. The server @@ -30,6 +32,35 @@ def __init__(self, message: str, status_code: Optional[int] = None): self.status_code = status_code +class _SyncFromAsyncTransport(httpx.BaseTransport): + """Adapts an async-only transport (httpx.ASGITransport, used by tests to + exercise the real app with no running server) so it works with the + synchronous httpx.Client used here. + + Runs one event loop per request and fully reads the response body inside + it, since the response stream from an async transport isn't safe to read + synchronously afterwards. Fine for tests; not meant for high throughput. + """ + + def __init__(self, async_transport: httpx.AsyncBaseTransport): + self._async_transport = async_transport + + def handle_request(self, request: httpx.Request) -> httpx.Response: + async def run() -> httpx.Response: + response = await self._async_transport.handle_async_request(request) + await response.aread() + return response + response = asyncio.run(run()) + # response.stream is async-only; httpx.Client requires a sync stream, + # so rebuild the response around the body already read above. + return httpx.Response(response.status_code, headers=response.headers, + content=response.content, + extensions=response.extensions, request=request) + + def close(self) -> None: + asyncio.run(self._async_transport.aclose()) + + class Fileglancer: """A client for the Fileglancer HTTP API. @@ -54,6 +85,11 @@ def __init__(self, url: Optional[str] = None, token: Optional[str] = None, "No API token. Pass token= or set FILEGLANCER_TOKEN. Create a " "token on the API Tokens page of the Fileglancer web UI.") + if transport is not None and not hasattr(transport, "handle_request"): + # An async-only transport (e.g. httpx.ASGITransport) can't be used + # directly by the synchronous httpx.Client below. + transport = _SyncFromAsyncTransport(transport) + self._client = httpx.Client( base_url=url.rstrip("/"), headers={"Authorization": f"Bearer {token}"}, @@ -150,3 +186,67 @@ def abspath(self, fsp_name: str, path: str = "") -> str: root = fsp.mount_path.rstrip("/") return f"{root}/{path}" if path else root raise FileglancerError(f"Unknown file share: {fsp_name}") + + # --- File operations --- + + def ls(self, path: str) -> List[FileInfo]: + """List the contents of a directory. + + Each returned FileInfo carries an absolute_path, so results can be fed + straight back into any other method. + """ + fsp_name, subpath = self._resolve(path) + data = self._request("GET", f"/api/files/{fsp_name}", + params={"subpath": subpath}).json() + return [FileInfo(**entry) for entry in data.get("files", [])] + + def stat(self, path: str) -> FileInfo: + """Get metadata for a single file or directory.""" + fsp_name, subpath = self._resolve(path) + data = self._request("GET", f"/api/files/{fsp_name}", + params={"subpath": subpath}).json() + return FileInfo(**data["info"]) + + def mkdir(self, path: str) -> None: + """Create a directory. The parent directory must already exist.""" + fsp_name, subpath = self._resolve(path) + self._request("POST", f"/api/files/{fsp_name}", + params={"subpath": subpath}, json={"type": "directory"}) + + def rename(self, src: str, dst: str) -> None: + """Rename or move a file or directory within one file share.""" + src_fsp, src_subpath = self._resolve(src) + dst_fsp, dst_subpath = self._resolve(dst) + if src_fsp != dst_fsp: + # The underlying PATCH /api/files cannot move across shares, so + # fail here with a useful message rather than on a 400. + raise FileglancerError( + f"Cannot move between file shares: {src!r} is on {src_fsp!r} " + f"but {dst!r} is on {dst_fsp!r}. Both must be on the same " + f"file share.") + self._request("PATCH", f"/api/files/{src_fsp}", + params={"subpath": src_subpath}, + json={"path": dst_subpath}) + + def delete(self, path: str) -> None: + """Delete a file or an empty directory.""" + fsp_name, subpath = self._resolve(path) + self._request("DELETE", f"/api/files/{fsp_name}", + params={"subpath": subpath}) + + def read(self, path: str) -> bytes: + """Read a file's contents.""" + fsp_name, subpath = self._resolve(path) + return self._request("GET", f"/api/content/{fsp_name}", + params={"subpath": subpath}).content + + def write(self, path: str, data: bytes) -> int: + """Write bytes to a file, creating or replacing it. + + The parent directory must already exist. Returns the number of bytes + written. + """ + fsp_name, subpath = self._resolve(path) + response = self._request("PUT", f"/api/content/{fsp_name}", + params={"subpath": subpath}, content=data) + return response.json()["bytes_written"] diff --git a/tests/test_client_files.py b/tests/test_client_files.py new file mode 100644 index 000000000..8fcf7f243 --- /dev/null +++ b/tests/test_client_files.py @@ -0,0 +1,130 @@ +"""Client file operations, driven against the real app over ASGI. + +Reuses the token_app fixture from test_api_token_auth so the client exercises +the full bearer-auth path rather than a dependency override. +""" +import os + +import httpx +import pytest + +from fileglancer.client import Fileglancer, FileglancerError +from fileglancer.database import create_api_token + +from test_api_token_auth import token_app # noqa: F401 + + +@pytest.fixture +def fg(token_app): + """A client wired to the test app via ASGI, with all scopes.""" + app, db_session = token_app + _, plaintext = create_api_token( + db_session, "alice", "test", + ["files:write", "links:write", "jobs:write"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + yield client + client.close() + + +@pytest.fixture +def share_root(token_app): + """The temp directory backing the 'tempdir' file share.""" + app, db_session = token_app + from fileglancer.database import get_file_share_paths + return get_file_share_paths(db_session)[0].mount_path + + +def test_ls_lists_the_share_root(fg, share_root): + os.makedirs(os.path.join(share_root, "adir"), exist_ok=True) + + names = [f.name for f in fg.ls(share_root)] + + assert "adir" in names + + +def test_ls_returns_absolute_paths(fg, share_root): + os.makedirs(os.path.join(share_root, "adir"), exist_ok=True) + + entry = next(f for f in fg.ls(share_root) if f.name == "adir") + + assert entry.absolute_path == os.path.join(share_root, "adir") + + +def test_stat_describes_the_path_itself(fg, share_root): + info = fg.stat(share_root) + + assert info.is_dir is True + + +def test_mkdir_creates_a_directory(fg, share_root): + target = os.path.join(share_root, "created") + + fg.mkdir(target) + + assert os.path.isdir(target) + + +def test_write_then_read_round_trips(fg, share_root): + target = os.path.join(share_root, "notes.txt") + + written = fg.write(target, b"hello world") + + assert written == 11 + assert fg.read(target) == b"hello world" + + +def test_rename_moves_within_a_share(fg, share_root): + src = os.path.join(share_root, "before.txt") + dst = os.path.join(share_root, "after.txt") + fg.write(src, b"x") + + fg.rename(src, dst) + + assert os.path.exists(dst) + assert not os.path.exists(src) + + +def test_rename_across_shares_is_refused_before_any_request(fg, share_root): + # '/tempdir/test/path' is the linux_path of the same share, so build a + # genuinely different one by pointing at a share that does not exist. + with pytest.raises(FileglancerError, match="No file share matches"): + fg.rename(os.path.join(share_root, "a.txt"), "/nowhere/b.txt") + + +def test_delete_removes_a_file(fg, share_root): + target = os.path.join(share_root, "doomed.txt") + fg.write(target, b"x") + + fg.delete(target) + + assert not os.path.exists(target) + + +def test_error_response_becomes_a_fileglancer_error(fg, share_root): + with pytest.raises(FileglancerError) as excinfo: + fg.ls(os.path.join(share_root, "does-not-exist")) + + assert excinfo.value.status_code in (403, 404) + + +def test_a_read_only_token_cannot_write(token_app, share_root): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "ro", ["files:read"]) + client = Fileglancer(url="http://testserver", token=plaintext, + transport=httpx.ASGITransport(app=app)) + + with pytest.raises(FileglancerError) as excinfo: + client.mkdir(os.path.join(share_root, "nope")) + + assert excinfo.value.status_code == 403 + client.close() + + +def test_rename_rejects_a_cross_share_move(fg, monkeypatch): + """Two resolvable paths on different shares must be refused locally.""" + monkeypatch.setattr(fg, "_resolve", lambda p: ( + ("shareA", "a.txt") if "a.txt" in p else ("shareB", "b.txt"))) + + with pytest.raises(FileglancerError, match="same file share"): + fg.rename("/x/a.txt", "/y/b.txt") From b517467d41683bde85b1b435b73ef751e1c899cd Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:51:03 -0400 Subject: [PATCH 11/43] fix: use TestClient instead of a custom async-transport adapter Replaces the _SyncFromAsyncTransport shim (test-only scaffolding that had leaked into the shipped client) with fastapi.testclient.TestClient, which already wraps an ASGI app as a synchronous httpx.Client. Drops the now-unneeded transport parameter from Fileglancer.__init__. --- fileglancer/client.py | 40 +------------------------------------- tests/test_client_files.py | 21 +++++++++++++------- 2 files changed, 15 insertions(+), 46 deletions(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index eb841e2f7..5a5775bce 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -9,7 +9,6 @@ fg = Fileglancer() # reads FILEGLANCER_URL and FILEGLANCER_TOKEN link = fg.create_data_link("/nearline/alice/sample.zarr") """ -import asyncio import os from typing import Any, Dict, List, Optional, Tuple @@ -32,35 +31,6 @@ def __init__(self, message: str, status_code: Optional[int] = None): self.status_code = status_code -class _SyncFromAsyncTransport(httpx.BaseTransport): - """Adapts an async-only transport (httpx.ASGITransport, used by tests to - exercise the real app with no running server) so it works with the - synchronous httpx.Client used here. - - Runs one event loop per request and fully reads the response body inside - it, since the response stream from an async transport isn't safe to read - synchronously afterwards. Fine for tests; not meant for high throughput. - """ - - def __init__(self, async_transport: httpx.AsyncBaseTransport): - self._async_transport = async_transport - - def handle_request(self, request: httpx.Request) -> httpx.Response: - async def run() -> httpx.Response: - response = await self._async_transport.handle_async_request(request) - await response.aread() - return response - response = asyncio.run(run()) - # response.stream is async-only; httpx.Client requires a sync stream, - # so rebuild the response around the body already read above. - return httpx.Response(response.status_code, headers=response.headers, - content=response.content, - extensions=response.extensions, request=request) - - def close(self) -> None: - asyncio.run(self._async_transport.aclose()) - - class Fileglancer: """A client for the Fileglancer HTTP API. @@ -69,12 +39,10 @@ class Fileglancer: token: An API token created in the web UI. Defaults to $FILEGLANCER_TOKEN. timeout: Per-request timeout in seconds. - transport: An httpx transport, for testing against an ASGI app. """ def __init__(self, url: Optional[str] = None, token: Optional[str] = None, - timeout: float = 60.0, - transport: Optional[httpx.BaseTransport] = None): + timeout: float = 60.0): url = url or os.environ.get("FILEGLANCER_URL") token = token or os.environ.get("FILEGLANCER_TOKEN") if not url: @@ -85,16 +53,10 @@ def __init__(self, url: Optional[str] = None, token: Optional[str] = None, "No API token. Pass token= or set FILEGLANCER_TOKEN. Create a " "token on the API Tokens page of the Fileglancer web UI.") - if transport is not None and not hasattr(transport, "handle_request"): - # An async-only transport (e.g. httpx.ASGITransport) can't be used - # directly by the synchronous httpx.Client below. - transport = _SyncFromAsyncTransport(transport) - self._client = httpx.Client( base_url=url.rstrip("/"), headers={"Authorization": f"Bearer {token}"}, timeout=timeout, - transport=transport, follow_redirects=True, ) self._fsp_cache: Optional[List[FileSharePath]] = None diff --git a/tests/test_client_files.py b/tests/test_client_files.py index 8fcf7f243..d957857b6 100644 --- a/tests/test_client_files.py +++ b/tests/test_client_files.py @@ -1,12 +1,12 @@ -"""Client file operations, driven against the real app over ASGI. +"""Client file operations, driven against the real app. Reuses the token_app fixture from test_api_token_auth so the client exercises the full bearer-auth path rather than a dependency override. """ import os -import httpx import pytest +from fastapi.testclient import TestClient from fileglancer.client import Fileglancer, FileglancerError from fileglancer.database import create_api_token @@ -16,13 +16,19 @@ @pytest.fixture def fg(token_app): - """A client wired to the test app via ASGI, with all scopes.""" + """A client wired to the test app, with all scopes. + + TestClient is a synchronous httpx.Client that speaks ASGI directly, so the + client's real request path is exercised with no server process. It also + defaults to follow_redirects=True and base_url="http://testserver". + """ app, db_session = token_app _, plaintext = create_api_token( db_session, "alice", "test", ["files:write", "links:write", "jobs:write"]) - client = Fileglancer(url="http://testserver", token=plaintext, - transport=httpx.ASGITransport(app=app)) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) yield client client.close() @@ -111,8 +117,9 @@ def test_error_response_becomes_a_fileglancer_error(fg, share_root): def test_a_read_only_token_cannot_write(token_app, share_root): app, db_session = token_app _, plaintext = create_api_token(db_session, "alice", "ro", ["files:read"]) - client = Fileglancer(url="http://testserver", token=plaintext, - transport=httpx.ASGITransport(app=app)) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) with pytest.raises(FileglancerError) as excinfo: client.mkdir(os.path.join(share_root, "nope")) From cffd9703fe26aa40c9b6b502f26603712b55387b Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 19:57:56 -0400 Subject: [PATCH 12/43] fix: ls() raises on a file instead of returning an empty list GET /api/files omits the files key for non-directories, so ls() on a file previously returned [], indistinguishable from an empty directory. Now checks info.is_dir and raises FileglancerError first. --- fileglancer/client.py | 7 +++++++ tests/test_client_files.py | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/fileglancer/client.py b/fileglancer/client.py index 5a5775bce..58660eb2a 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -156,10 +156,17 @@ def ls(self, path: str) -> List[FileInfo]: Each returned FileInfo carries an absolute_path, so results can be fed straight back into any other method. + + Raises FileglancerError if the path is not a directory. The API omits + the file list for non-directories, so without this check ls() on a + file would return an empty list, which a caller would read as an + empty directory. """ fsp_name, subpath = self._resolve(path) data = self._request("GET", f"/api/files/{fsp_name}", params={"subpath": subpath}).json() + if not data["info"].get("is_dir"): + raise FileglancerError(f"Not a directory: {path}") return [FileInfo(**entry) for entry in data.get("files", [])] def stat(self, path: str) -> FileInfo: diff --git a/tests/test_client_files.py b/tests/test_client_files.py index d957857b6..feed3e9a3 100644 --- a/tests/test_client_files.py +++ b/tests/test_client_files.py @@ -107,6 +107,15 @@ def test_delete_removes_a_file(fg, share_root): assert not os.path.exists(target) +def test_ls_on_a_file_raises_rather_than_returning_empty(fg, share_root): + """A file must not look like an empty directory.""" + target = os.path.join(share_root, "notafolder.txt") + fg.write(target, b"x") + + with pytest.raises(FileglancerError, match="Not a directory"): + fg.ls(target) + + def test_error_response_becomes_a_fileglancer_error(fg, share_root): with pytest.raises(FileglancerError) as excinfo: fg.ls(os.path.join(share_root, "does-not-exist")) From 04a9f1509f84323d351ccb8cff8ec2d509c28502 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:01:58 -0400 Subject: [PATCH 13/43] feat: add data link and Neuroglancer link methods to the client Adds create_data_link, data_links, data_link, delete_data_link, create_ng_link, ng_links, and delete_ng_link to Fileglancer, completing the create-link -> build-state -> shorten-link workflow. Every returned ProxiedPath has its path field rewritten to an absolute path. --- fileglancer/client.py | 78 +++++++++++++++++++++- tests/test_client_links.py | 133 +++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tests/test_client_links.py diff --git a/fileglancer/client.py b/fileglancer/client.py index 58660eb2a..8ea74e70e 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -15,7 +15,7 @@ import httpx from fileglancer.filestore import FileInfo -from fileglancer.model import FileSharePath +from fileglancer.model import FileSharePath, NeuroglancerShortLink, ProxiedPath # Neuroglancer base URL used when the caller does not supply one. The server # has no configured default and POST /api/neuroglancer/nglinks requires @@ -219,3 +219,79 @@ def write(self, path: str, data: bytes) -> int: response = self._request("PUT", f"/api/content/{fsp_name}", params={"subpath": subpath}, content=data) return response.json()["bytes_written"] + + # --- Data links --- + + def _absolutize(self, link: ProxiedPath) -> ProxiedPath: + """Rewrite a ProxiedPath's FSP-relative path to an absolute one. + + The REST API defines ProxiedPath.path as relative to the file share. + This client presents absolute paths throughout, so the value is + replaced here. fsp_name is left in place for reference. + """ + return link.model_copy( + update={"path": self.abspath(link.fsp_name, link.path)}) + + def create_data_link(self, path: str, + url_prefix: Optional[str] = None) -> ProxiedPath: + """Create a data link that serves a folder over HTTP. + + Args: + path: Absolute path to the folder to share. + url_prefix: The URL segment after the sharing key. Defaults to the + folder's basename. + """ + fsp_name, subpath = self._resolve(path) + params: Dict[str, Any] = {"fsp_name": fsp_name, "path": subpath} + if url_prefix is not None: + params["url_prefix"] = url_prefix + data = self._request("POST", "/api/proxied-path", params=params).json() + return self._absolutize(ProxiedPath(**data)) + + def data_links(self) -> List[ProxiedPath]: + """List the caller's data links.""" + data = self._request("GET", "/api/proxied-path").json() + return [self._absolutize(ProxiedPath(**p)) for p in data["paths"]] + + def data_link(self, sharing_key: str) -> ProxiedPath: + """Get one data link by its sharing key.""" + data = self._request("GET", f"/api/proxied-path/{sharing_key}").json() + return self._absolutize(ProxiedPath(**data)) + + def delete_data_link(self, sharing_key: str) -> None: + """Delete a data link.""" + self._request("DELETE", f"/api/proxied-path/{sharing_key}") + + # --- Neuroglancer links --- + + def create_ng_link(self, state: Dict[str, Any], + url_base: str = NEUROGLANCER_URL, + title: Optional[str] = None, + short_name: Optional[str] = None) -> str: + """Store a Neuroglancer state and return a shortened viewer URL. + + Args: + state: A Neuroglancer state as a plain dict. This is exactly what + neuroglancer.ViewerState.to_json() returns, so no dependency + on the neuroglancer package is needed. + url_base: The Neuroglancer instance the link should open in. + title: Optional title shown in the browser tab. + short_name: Optional human-friendly suffix for the link. + """ + payload: Dict[str, Any] = {"state": state, "url_base": url_base} + if title is not None: + payload["title"] = title + if short_name is not None: + payload["short_name"] = short_name + data = self._request("POST", "/api/neuroglancer/nglinks", + json=payload).json() + return data["neuroglancer_url"] + + def ng_links(self) -> List[NeuroglancerShortLink]: + """List the caller's stored Neuroglancer links.""" + data = self._request("GET", "/api/neuroglancer/nglinks").json() + return [NeuroglancerShortLink(**link) for link in data["links"]] + + def delete_ng_link(self, short_key: str) -> None: + """Delete a stored Neuroglancer link.""" + self._request("DELETE", f"/api/neuroglancer/nglinks/{short_key}") diff --git a/tests/test_client_links.py b/tests/test_client_links.py new file mode 100644 index 000000000..db3775e87 --- /dev/null +++ b/tests/test_client_links.py @@ -0,0 +1,133 @@ +"""Client data-link and Neuroglancer-link operations.""" +import os + +import pytest +from fastapi.testclient import TestClient + +from fileglancer.client import NEUROGLANCER_URL, Fileglancer, FileglancerError +from fileglancer.database import create_api_token, get_file_share_paths + +from test_api_token_auth import token_app # noqa: F401 + + +@pytest.fixture +def fg(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "test", + ["files:write", "links:write"]) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + yield client + client.close() + + +@pytest.fixture +def shared_dir(token_app): + """A real directory inside the test share, ready to be linked.""" + _, db_session = token_app + root = get_file_share_paths(db_session)[0].mount_path + path = os.path.join(root, "sample.zarr") + os.makedirs(path, exist_ok=True) + return path + + +def test_create_data_link_from_an_absolute_path(fg, shared_dir): + link = fg.create_data_link(shared_dir) + + assert link.fsp_name == "tempdir" + assert link.sharing_key + assert str(link.url).endswith("/sample.zarr") + + +def test_created_data_link_reports_an_absolute_path(fg, shared_dir): + link = fg.create_data_link(shared_dir) + + assert link.path == shared_dir + + +def test_create_data_link_accepts_a_url_prefix(fg, shared_dir): + link = fg.create_data_link(shared_dir, url_prefix="custom") + + assert str(link.url).endswith("/custom") + + +def test_create_data_link_rejects_an_unresolvable_path(fg): + with pytest.raises(FileglancerError, match="No file share matches"): + fg.create_data_link("/nowhere/at/all") + + +def test_list_data_links_reports_absolute_paths(fg, shared_dir): + fg.create_data_link(shared_dir) + + links = fg.data_links() + + assert [link.path for link in links] == [shared_dir] + + +def test_get_data_link_by_sharing_key(fg, shared_dir): + created = fg.create_data_link(shared_dir) + + fetched = fg.data_link(created.sharing_key) + + assert fetched.sharing_key == created.sharing_key + assert fetched.path == shared_dir + + +def test_delete_data_link(fg, shared_dir): + created = fg.create_data_link(shared_dir) + + fg.delete_data_link(created.sharing_key) + + assert fg.data_links() == [] + + +def test_create_ng_link_returns_a_neuroglancer_url(fg): + url = fg.create_ng_link({"layers": []}, title="sample") + + assert url.startswith(NEUROGLANCER_URL + "#!") + assert "/ng/" in url + + +def test_create_ng_link_honours_a_custom_url_base(fg): + url = fg.create_ng_link({"layers": []}, url_base="https://ng.example.com") + + assert url.startswith("https://ng.example.com#!") + + +def test_create_ng_link_with_a_short_name(fg): + url = fg.create_ng_link({"layers": []}, short_name="my-view") + + assert url.endswith("/my-view") + + +def test_list_ng_links(fg): + fg.create_ng_link({"layers": []}, title="sample") + + links = fg.ng_links() + + assert len(links) == 1 + + +def test_delete_ng_link(fg): + fg.create_ng_link({"layers": []}, short_name="doomed") + short_key = fg.ng_links()[0].short_key + + fg.delete_ng_link(short_key) + + assert fg.ng_links() == [] + + +def test_a_files_only_token_cannot_create_a_data_link(token_app, shared_dir): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "files-only", + ["files:write"]) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + + with pytest.raises(FileglancerError) as excinfo: + client.create_data_link(shared_dir) + + assert excinfo.value.status_code == 403 + client.close() From c978cc7cdbd8369da1461da9b0132edd28dbf392 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:07:38 -0400 Subject: [PATCH 14/43] feat: add job methods to the Python client --- fileglancer/client.py | 30 ++++++++++++++- tests/test_client_jobs.py | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 tests/test_client_jobs.py diff --git a/fileglancer/client.py b/fileglancer/client.py index 8ea74e70e..e1b148fa0 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -15,7 +15,7 @@ import httpx from fileglancer.filestore import FileInfo -from fileglancer.model import FileSharePath, NeuroglancerShortLink, ProxiedPath +from fileglancer.model import FileSharePath, Job, NeuroglancerShortLink, ProxiedPath # Neuroglancer base URL used when the caller does not supply one. The server # has no configured default and POST /api/neuroglancer/nglinks requires @@ -295,3 +295,31 @@ def ng_links(self) -> List[NeuroglancerShortLink]: def delete_ng_link(self, short_key: str) -> None: """Delete a stored Neuroglancer link.""" self._request("DELETE", f"/api/neuroglancer/nglinks/{short_key}") + + # --- Jobs --- + + def jobs(self, status: Optional[str] = None) -> List[Job]: + """List the caller's jobs, optionally filtered by status.""" + params = {"status": status} if status else None + data = self._request("GET", "/api/jobs", params=params).json() + return [Job(**job) for job in data["jobs"]] + + def job(self, job_id: int) -> Job: + """Get a single job by id.""" + return Job(**self._request("GET", f"/api/jobs/{job_id}").json()) + + def submit_job(self, app_url: str, entry_point_id: str, **kwargs) -> Job: + """Submit a job. + + Args: + app_url: The app's repository URL. + entry_point_id: Which entry point of the app to run. + **kwargs: Any other field accepted by the /api/jobs endpoint, such + as parameters, resources, name, env, or container. + """ + payload = {"app_url": app_url, "entry_point_id": entry_point_id, **kwargs} + return Job(**self._request("POST", "/api/jobs", json=payload).json()) + + def cancel_job(self, job_id: int) -> None: + """Cancel a running or pending job.""" + self._request("POST", f"/api/jobs/{job_id}/cancel") diff --git a/tests/test_client_jobs.py b/tests/test_client_jobs.py new file mode 100644 index 000000000..22dc773ee --- /dev/null +++ b/tests/test_client_jobs.py @@ -0,0 +1,77 @@ +"""Client job operations. + +Job submission needs a real app manifest and scheduler, so these tests cover +the request shaping and scope enforcement rather than an end-to-end launch. +""" +import pytest +from fastapi.testclient import TestClient + +from fileglancer.client import Fileglancer, FileglancerError +from fileglancer.database import create_api_token + +from test_api_token_auth import token_app # noqa: F401 + + +@pytest.fixture +def fg(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "test", ["jobs:write"]) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + yield client + client.close() + + +def test_jobs_is_empty_for_a_new_user(fg): + assert fg.jobs() == [] + + +def test_jobs_accepts_a_status_filter(fg): + assert fg.jobs(status="RUNNING") == [] + + +def test_unknown_job_id_raises(fg): + with pytest.raises(FileglancerError) as excinfo: + fg.job(99999) + + assert excinfo.value.status_code == 404 + + +def test_submit_job_sends_app_url_and_entry_point(fg): + # No such app is registered, so this fails at the server. What matters is + # that the request was well-formed enough to reach that check. + with pytest.raises(FileglancerError) as excinfo: + fg.submit_job(app_url="https://github.com/example/none", + entry_point_id="main") + + assert excinfo.value.status_code in (400, 500) + + +def test_a_read_only_jobs_token_cannot_submit(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "ro", ["jobs:read"]) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + + with pytest.raises(FileglancerError) as excinfo: + client.submit_job(app_url="https://github.com/example/none", + entry_point_id="main") + + assert excinfo.value.status_code == 403 + client.close() + + +def test_a_links_only_token_cannot_list_jobs(token_app): + app, db_session = token_app + _, plaintext = create_api_token(db_session, "alice", "links", ["links:read"]) + client = Fileglancer(url="http://testserver", token=plaintext) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + + with pytest.raises(FileglancerError) as excinfo: + client.jobs() + + assert excinfo.value.status_code == 403 + client.close() From 17b5869fcd471a6413346a2261c1d32a70a5b6cf Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:15:10 -0400 Subject: [PATCH 15/43] fix: require parameters explicitly in submit_job, add payload-shaping test --- fileglancer/client.py | 13 ++++++++++--- tests/test_client_jobs.py | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index e1b148fa0..72d728ab7 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -308,16 +308,23 @@ def job(self, job_id: int) -> Job: """Get a single job by id.""" return Job(**self._request("GET", f"/api/jobs/{job_id}").json()) - def submit_job(self, app_url: str, entry_point_id: str, **kwargs) -> Job: + def submit_job(self, app_url: str, entry_point_id: str, + parameters: Optional[Dict[str, Any]] = None, + **kwargs) -> Job: """Submit a job. Args: app_url: The app's repository URL. entry_point_id: Which entry point of the app to run. + parameters: Values for the entry point's parameters. Required by + the API, so it defaults to an empty dict rather than being + left out — an app whose entry point takes no parameters is + submitted with no arguments. **kwargs: Any other field accepted by the /api/jobs endpoint, such - as parameters, resources, name, env, or container. + as resources, name, env, or container. """ - payload = {"app_url": app_url, "entry_point_id": entry_point_id, **kwargs} + payload = {"app_url": app_url, "entry_point_id": entry_point_id, + "parameters": parameters or {}, **kwargs} return Job(**self._request("POST", "/api/jobs", json=payload).json()) def cancel_job(self, job_id: int) -> None: diff --git a/tests/test_client_jobs.py b/tests/test_client_jobs.py index 22dc773ee..c88ebd52e 100644 --- a/tests/test_client_jobs.py +++ b/tests/test_client_jobs.py @@ -3,6 +3,9 @@ Job submission needs a real app manifest and scheduler, so these tests cover the request shaping and scope enforcement rather than an end-to-end launch. """ +import json + +import httpx import pytest from fastapi.testclient import TestClient @@ -39,8 +42,9 @@ def test_unknown_job_id_raises(fg): def test_submit_job_sends_app_url_and_entry_point(fg): - # No such app is registered, so this fails at the server. What matters is - # that the request was well-formed enough to reach that check. + # parameters is required by the API and now defaults to {}, so this + # request is well-formed and reaches the server's app-lookup, which + # fails because no such app is registered. with pytest.raises(FileglancerError) as excinfo: fg.submit_job(app_url="https://github.com/example/none", entry_point_id="main") @@ -48,6 +52,30 @@ def test_submit_job_sends_app_url_and_entry_point(fg): assert excinfo.value.status_code in (400, 500) +def test_submit_job_payload_includes_parameters_and_passthrough(fg): + """The old version of this test passed even with broken payload shaping, + because the request was rejected before its content was inspected.""" + sent = {} + + def handler(request): + sent.update(json.loads(request.content)) + return httpx.Response(400, json={"error": "no such app"}) + + fg._client = httpx.Client(base_url="http://testserver", + transport=httpx.MockTransport(handler)) + + with pytest.raises(FileglancerError): + fg.submit_job(app_url="https://github.com/example/none", + entry_point_id="main", name="my job") + + assert sent == { + "app_url": "https://github.com/example/none", + "entry_point_id": "main", + "parameters": {}, + "name": "my job", + } + + def test_a_read_only_jobs_token_cannot_submit(token_app): app, db_session = token_app _, plaintext = create_api_token(db_session, "alice", "ro", ["jobs:read"]) From 5282521801637e6961fcb374eb3b96f22476ba4d Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:19:22 -0400 Subject: [PATCH 16/43] feat: add frontend TanStack Query hooks for API tokens Adds useApiTokensQuery/useCreateApiTokenMutation/useDeleteApiTokenMutation in frontend/src/queries/apiTokenQueries.ts, modeled on sshKeyQueries.ts, to back the upcoming token management UI (Task 9 of the API tokens plan). Also adds two resolvePathToFsp regression tests (prefix-boundary and longest-match cases) confirming the TS resolver's prefix guard matches the behaviour the Python client mirrors. --- .../unitTests/apiTokenQueries.test.tsx | 115 +++++++++++++ .../__tests__/unitTests/pathHandling.test.ts | 47 ++++++ frontend/src/queries/apiTokenQueries.ts | 155 ++++++++++++++++++ 3 files changed, 317 insertions(+) create mode 100644 frontend/src/__tests__/unitTests/apiTokenQueries.test.tsx create mode 100644 frontend/src/queries/apiTokenQueries.ts diff --git a/frontend/src/__tests__/unitTests/apiTokenQueries.test.tsx b/frontend/src/__tests__/unitTests/apiTokenQueries.test.tsx new file mode 100644 index 000000000..c9ad30282 --- /dev/null +++ b/frontend/src/__tests__/unitTests/apiTokenQueries.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import * as utils from '@/utils'; +import { + API_SCOPES, + useApiTokensQuery, + useCreateApiTokenMutation +} from '@/queries/apiTokenQueries'; + +// Mock the utils module so we can control sendFetchRequest's resolved +// Response directly, without depending on its internal fetch/health-check +// handling. +vi.mock('@/utils', async () => { + const actual = await vi.importActual('@/utils'); + return { + ...actual, + sendFetchRequest: vi.fn() + }; +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return {children}; +}; + +describe('apiTokenQueries', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('exposes the six documented scopes', () => { + expect([...API_SCOPES].sort()).toEqual([ + 'files:read', + 'files:write', + 'jobs:read', + 'jobs:write', + 'links:read', + 'links:write' + ]); + }); + + it('returns the token list', async () => { + vi.mocked(utils.sendFetchRequest).mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + tokens: [ + { + token_id: 'abc123', + name: 'laptop', + scopes: ['files:read'], + created_at: '2026-08-24T00:00:00Z', + expires_at: '2026-09-23T00:00:00Z', + last_used_at: null + } + ] + }) + } as unknown as Response); + + const { result } = renderHook(() => useApiTokensQuery(), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.[0].name).toBe('laptop'); + }); + + it('returns the one-time secret from a create', async () => { + vi.mocked(utils.sendFetchRequest).mockResolvedValue({ + ok: true, + status: 201, + json: async () => ({ + token: { + token_id: 'abc123', + name: 'laptop', + scopes: ['files:read'], + created_at: '2026-08-24T00:00:00Z', + expires_at: '2026-09-23T00:00:00Z', + last_used_at: null + }, + secret: 'fgt_abc123_supersecret' + }) + } as unknown as Response); + + const { result } = renderHook(() => useCreateApiTokenMutation(), { + wrapper + }); + const created = await result.current.mutateAsync({ + name: 'laptop', + scopes: ['files:read'], + expires_in_days: 30 + }); + + expect(created.secret).toBe('fgt_abc123_supersecret'); + }); + + it('surfaces an error response', async () => { + vi.mocked(utils.sendFetchRequest).mockResolvedValue({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: async () => ({ error: 'Unknown scopes: nope' }) + } as unknown as Response); + + const { result } = renderHook(() => useCreateApiTokenMutation(), { + wrapper + }); + + await expect( + result.current.mutateAsync({ name: 'x', scopes: ['nope'] }) + ).rejects.toThrow(); + }); +}); diff --git a/frontend/src/__tests__/unitTests/pathHandling.test.ts b/frontend/src/__tests__/unitTests/pathHandling.test.ts index a2fff14fb..5a8529ad1 100644 --- a/frontend/src/__tests__/unitTests/pathHandling.test.ts +++ b/frontend/src/__tests__/unitTests/pathHandling.test.ts @@ -389,6 +389,53 @@ describe('resolvePathToFsp', () => { expect(result!.fsp.name).toBe('fsp_nulls'); expect(result!.subpath).toBe('sub'); }); + + test('does not match a share whose path is a string prefix of another', () => { + const fspPublic = { + zone: 'Zone1', + name: 'fsp_public', + mount_path: '/misc/public', + linux_path: '/misc/public' + } as FileSharePath; + const fspArchive = { + zone: 'Zone1', + name: 'fsp_public_archive', + mount_path: '/misc/public-archive', + linux_path: '/misc/public-archive' + } as FileSharePath; + const data: Record = { + fsp_fsp_public: fspPublic, + fsp_fsp_public_archive: fspArchive + }; + + const result = resolvePathToFsp('/misc/public-archive/x', data); + + expect(result).not.toBeNull(); + expect(result!.fsp.name).toBe('fsp_public_archive'); + expect(result!.subpath).toBe('x'); + }); + + test('longest matching prefix wins', () => { + const fspOuter = { + zone: 'Zone1', + name: 'fsp_outer', + mount_path: '/data' + } as FileSharePath; + const fspInner = { + zone: 'Zone1', + name: 'fsp_inner', + mount_path: '/data/projects' + } as FileSharePath; + const data: Record = { + fsp_fsp_outer: fspOuter, + fsp_fsp_inner: fspInner + }; + + const result = resolvePathToFsp('/data/projects/thing', data); + + expect(result!.fsp.name).toBe('fsp_inner'); + expect(result!.subpath).toBe('thing'); + }); }); describe('getPreferredPathForDisplay', () => { diff --git a/frontend/src/queries/apiTokenQueries.ts b/frontend/src/queries/apiTokenQueries.ts new file mode 100644 index 000000000..f55365b98 --- /dev/null +++ b/frontend/src/queries/apiTokenQueries.ts @@ -0,0 +1,155 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import type { UseQueryResult, UseMutationResult } from '@tanstack/react-query'; + +import { sendFetchRequest } from '@/utils'; +import { + getResponseJsonOrError, + throwResponseNotOkError +} from '@/queries/queryUtils'; + +/** + * The scopes an API token can grant. Must match API_SCOPES in + * fileglancer/auth.py. + */ +export const API_SCOPES = [ + 'files:read', + 'files:write', + 'links:read', + 'links:write', + 'jobs:read', + 'jobs:write' +] as const; + +export type ApiScope = (typeof API_SCOPES)[number]; + +/** + * An API token, without its secret. + */ +export type ApiTokenInfo = { + token_id: string; + name: string; + scopes: string[]; + created_at: string; + expires_at: string; + last_used_at: string | null; +}; + +type ApiTokenListResponse = { + tokens: ApiTokenInfo[]; +}; + +/** + * The result of creating a token. The secret is shown once and is not + * recoverable afterwards. + */ +export type CreateTokenResult = { + token: ApiTokenInfo; + secret: string; +}; + +export type CreateTokenParams = { + name: string; + scopes: string[]; + expires_in_days?: number; +}; + +// Query key factory for API tokens +export const apiTokenQueryKeys = { + all: ['apiTokens'] as const, + list: () => ['apiTokens', 'list'] as const +}; + +/** + * Fetches all API tokens for the current user from the backend + */ +const fetchApiTokens = async ( + signal?: AbortSignal +): Promise => { + const response = await sendFetchRequest('/api/tokens', 'GET', undefined, { + signal + }); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return (body as ApiTokenListResponse).tokens ?? []; +}; + +/** + * Query hook for fetching all API tokens for the current user + * + * @returns Query result with API tokens + */ +export function useApiTokensQuery(): UseQueryResult { + return useQuery({ + queryKey: apiTokenQueryKeys.list(), + queryFn: ({ signal }) => fetchApiTokens(signal) + }); +} + +/** + * Mutation hook for creating an API token. + * + * The returned secret is the only copy; the server keeps only a hash of it. + */ +export function useCreateApiTokenMutation(): UseMutationResult< + CreateTokenResult, + Error, + CreateTokenParams +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params: CreateTokenParams) => { + const response = await sendFetchRequest('/api/tokens', 'POST', { + name: params.name, + scopes: params.scopes, + expires_in_days: params.expires_in_days ?? 30 + }); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return body as CreateTokenResult; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: apiTokenQueryKeys.all }); + } + }); +} + +/** + * Mutation hook for revoking an API token by its public token_id. + */ +export function useDeleteApiTokenMutation(): UseMutationResult< + void, + Error, + string +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (tokenId: string) => { + // sendFetchRequest strips the body from DELETE requests, so the id + // goes in the path instead. + const response = await sendFetchRequest( + `/api/tokens/${encodeURIComponent(tokenId)}`, + 'DELETE' + ); + + if (!response.ok) { + const body = await getResponseJsonOrError(response); + throwResponseNotOkError(response, body); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: apiTokenQueryKeys.all }); + } + }); +} From 98966720b65f4394c44cc7b4a4565e5a8cc4118c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:25:56 -0400 Subject: [PATCH 17/43] fix: revert unnecessary resolvePathToFsp tests from API tokens task Review found the two resolvePathToFsp tests added alongside the API token query hooks were redundant with pre-existing coverage, and in one case did not actually exercise the prefix-boundary guard it claimed to test. Reverting pathHandling.test.ts to its exact pre-existing state; the apiTokenQueries module and its tests are unaffected. --- .../__tests__/unitTests/pathHandling.test.ts | 47 ------------------- 1 file changed, 47 deletions(-) diff --git a/frontend/src/__tests__/unitTests/pathHandling.test.ts b/frontend/src/__tests__/unitTests/pathHandling.test.ts index 5a8529ad1..a2fff14fb 100644 --- a/frontend/src/__tests__/unitTests/pathHandling.test.ts +++ b/frontend/src/__tests__/unitTests/pathHandling.test.ts @@ -389,53 +389,6 @@ describe('resolvePathToFsp', () => { expect(result!.fsp.name).toBe('fsp_nulls'); expect(result!.subpath).toBe('sub'); }); - - test('does not match a share whose path is a string prefix of another', () => { - const fspPublic = { - zone: 'Zone1', - name: 'fsp_public', - mount_path: '/misc/public', - linux_path: '/misc/public' - } as FileSharePath; - const fspArchive = { - zone: 'Zone1', - name: 'fsp_public_archive', - mount_path: '/misc/public-archive', - linux_path: '/misc/public-archive' - } as FileSharePath; - const data: Record = { - fsp_fsp_public: fspPublic, - fsp_fsp_public_archive: fspArchive - }; - - const result = resolvePathToFsp('/misc/public-archive/x', data); - - expect(result).not.toBeNull(); - expect(result!.fsp.name).toBe('fsp_public_archive'); - expect(result!.subpath).toBe('x'); - }); - - test('longest matching prefix wins', () => { - const fspOuter = { - zone: 'Zone1', - name: 'fsp_outer', - mount_path: '/data' - } as FileSharePath; - const fspInner = { - zone: 'Zone1', - name: 'fsp_inner', - mount_path: '/data/projects' - } as FileSharePath; - const data: Record = { - fsp_fsp_outer: fspOuter, - fsp_fsp_inner: fspInner - }; - - const result = resolvePathToFsp('/data/projects/thing', data); - - expect(result!.fsp.name).toBe('fsp_inner'); - expect(result!.subpath).toBe('thing'); - }); }); describe('getPreferredPathForDisplay', () => { From ef14879d095e0e396186734a38ae221edfbb8880 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:31:53 -0400 Subject: [PATCH 18/43] feat: add API tokens page with create, list, and revoke Adds the user-facing API Tokens page (list, revoke, create-dialog with one-time secret display) consuming the completed apiTokenQueries hooks, registered unconditionally at /api-tokens and linked from the profile menu. Uses the existing FgDialog wrapper and design-system form atoms (FgFormField/FgInput/FgSelect/FgCheckbox) rather than raw Material Tailwind dialog/inputs, following SSHKeys/TempKeyDialog precedent. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/App.tsx | 9 ++ .../componentTests/ApiTokens.test.tsx | 131 +++++++++++++++++ frontend/src/components/ApiTokens.tsx | 112 ++++++++++++++ .../components/ui/ApiTokens/ApiTokenCard.tsx | 57 ++++++++ .../ui/ApiTokens/CreateTokenDialog.tsx | 138 ++++++++++++++++++ .../ui/ApiTokens/NewTokenDialog.tsx | 90 ++++++++++++ .../src/components/ui/Navbar/ProfileMenu.tsx | 11 +- 7 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/componentTests/ApiTokens.test.tsx create mode 100644 frontend/src/components/ApiTokens.tsx create mode 100644 frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx create mode 100644 frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx create mode 100644 frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d8f912c10..cb5e791fa 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import Links from '@/components/Links'; import NGLinks from '@/components/NGLinks'; import Notifications from '@/components/Notifications'; import SSHKeys from '@/components/SSHKeys'; +import ApiTokens from '@/components/ApiTokens'; import ErrorFallback from '@/components/ErrorFallback'; import { NGLinkProvider } from '@/contexts/NGLinkContext'; @@ -160,6 +161,14 @@ const AppComponent = () => { path="ssh-keys" /> ) : null} + + + + } + path="api-tokens" + /> { + const actual = await vi.importActual< + typeof import('@/queries/apiTokenQueries') + >('@/queries/apiTokenQueries'); + return { + API_SCOPES: actual.API_SCOPES, + useApiTokensQuery: () => mockUseApiTokensQuery(), + useCreateApiTokenMutation: () => mockUseCreateApiTokenMutation(), + useDeleteApiTokenMutation: () => mockUseDeleteApiTokenMutation() + }; +}); + +const existingToken: ApiTokenInfo = { + token_id: 'tok_123', + name: 'laptop notebook', + scopes: ['files:read', 'files:write'], + created_at: '2026-01-01T00:00:00Z', + expires_at: '2027-01-01T00:00:00Z', + last_used_at: null +}; + +const mockDeleteMutate = vi.fn(); +const mockCreateMutateAsync = vi.fn(); + +function setTokens(tokens: ApiTokenInfo[]) { + mockUseApiTokensQuery.mockReturnValue({ + data: tokens, + isLoading: false, + error: null, + refetch: vi.fn() + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockUseCreateApiTokenMutation.mockReturnValue({ + mutateAsync: mockCreateMutateAsync, + isPending: false, + error: null + }); + mockUseDeleteApiTokenMutation.mockReturnValue({ + mutate: mockDeleteMutate, + isPending: false + }); +}); + +describe('ApiTokens page', () => { + it('renders the empty state when there are no tokens', () => { + setTokens([]); + + render(); + + expect(screen.getByText('No API tokens')).toBeInTheDocument(); + expect(screen.queryByTestId('api-token-list')).not.toBeInTheDocument(); + }); + + it('renders a token name and its scopes when the list is non-empty', () => { + setTokens([existingToken]); + + render(); + + expect(screen.getByText('laptop notebook')).toBeInTheDocument(); + expect(screen.getByText('files:read, files:write')).toBeInTheDocument(); + }); + + it('calls the delete mutation with the token id when Revoke is clicked', async () => { + setTokens([existingToken]); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /revoke/i })); + + expect(mockDeleteMutate).toHaveBeenCalledWith('tok_123'); + }); + + it('shows the secret exactly once after creating a token, and the listing never renders it', async () => { + setTokens([existingToken]); + const secret = 'fgt_super_secret_value'; + const createdResult: CreateTokenResult = { + token: { + token_id: 'tok_456', + name: 'new token', + scopes: ['files:read'], + created_at: '2026-08-24T00:00:00Z', + expires_at: '2026-09-23T00:00:00Z', + last_used_at: null + }, + secret + }; + mockCreateMutateAsync.mockResolvedValue(createdResult); + const user = userEvent.setup(); + + render(); + + // The secret must not be present anywhere before creation. + expect(screen.queryByText(new RegExp(secret))).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.type(screen.getByLabelText('Name'), 'new token'); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + // The one-time secret dialog shows the secret. + const secretText = await screen.findByText(new RegExp(secret)); + expect(secretText).toBeInTheDocument(); + + // The token listing (rendered from useApiTokensQuery data) never + // includes the secret, even while the one-time dialog is open. + const list = screen.getByTestId('api-token-list'); + expect( + within(list).queryByText(new RegExp(secret)) + ).not.toBeInTheDocument(); + + // Only the one-time dialog renders the secret - a single occurrence. + expect(screen.getAllByText(new RegExp(secret))).toHaveLength(1); + }); +}); diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx new file mode 100644 index 000000000..f550bf8e2 --- /dev/null +++ b/frontend/src/components/ApiTokens.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { Card, Typography } from '@material-tailwind/react'; +import { HiOutlineKey, HiOutlinePlus } from 'react-icons/hi'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import ApiTokenCard from '@/components/ui/ApiTokens/ApiTokenCard'; +import CreateTokenDialog from '@/components/ui/ApiTokens/CreateTokenDialog'; +import NewTokenDialog from '@/components/ui/ApiTokens/NewTokenDialog'; +import { Spinner } from '@/components/ui/widgets/Loaders'; +import { + useApiTokensQuery, + useDeleteApiTokenMutation +} from '@/queries/apiTokenQueries'; +import type { CreateTokenResult } from '@/queries/apiTokenQueries'; + +export default function ApiTokens() { + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newToken, setNewToken] = useState(null); + const { data, isLoading, error, refetch } = useApiTokensQuery(); + const deleteToken = useDeleteApiTokenMutation(); + + const tokens = data ?? []; + const hasTokens = tokens.length > 0; + + return ( + <> + + API Tokens + + + + API tokens let scripts and notebooks use Fileglancer through the{' '} + fileglancer Python package. A token acts on your behalf, + limited to the scopes you grant it. The token is shown only once, when + you create it. + + + {isLoading ? ( +
+ +
+ ) : null} + + {error ? ( + + + Failed to load API tokens: {error.message} + + refetch()} size="sm"> + Retry + + + ) : null} + + {!isLoading && !error && !hasTokens ? ( + + + + No API tokens + + + Create a token to use Fileglancer from Python. + + setShowCreateDialog(true)} + size="sm" + > + New Token + + + ) : null} + + {!isLoading && !error && hasTokens ? ( +
+
+ setShowCreateDialog(true)} + size="sm" + > + New Token + +
+
+ {tokens.map(token => ( + deleteToken.mutate(id)} + token={token} + /> + ))} +
+
+ ) : null} + + + + setNewToken(null)} result={newToken} /> + + ); +} diff --git a/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx new file mode 100644 index 000000000..4da851915 --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx @@ -0,0 +1,57 @@ +import { Card, Typography } from '@material-tailwind/react'; +import { HiOutlineTrash } from 'react-icons/hi'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import type { ApiTokenInfo } from '@/queries/apiTokenQueries'; + +function formatDate(value: string | null): string { + if (!value) { + return 'Never'; + } + return new Date(value).toLocaleDateString(); +} + +export default function ApiTokenCard({ + token, + onRevoke, + isRevoking +}: { + readonly token: ApiTokenInfo; + readonly onRevoke: (tokenId: string) => void; + readonly isRevoking: boolean; +}) { + const isExpired = new Date(token.expires_at) < new Date(); + + return ( + +
+
+ + {token.name} + {isExpired ? ( + + Expired + + ) : null} + + + {token.scopes.join(', ')} + + + Created {formatDate(token.created_at)} · Expires{' '} + {formatDate(token.expires_at)} · Last used{' '} + {formatDate(token.last_used_at)} + +
+ onRevoke(token.token_id)} + size="sm" + > + Revoke + +
+
+ ); +} diff --git a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx new file mode 100644 index 000000000..c4aa977fd --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import { Typography } from '@material-tailwind/react'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgCheckbox from '@/components/designSystem/atoms/formElements/FgCheckbox'; +import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; +import FgSelect from '@/components/designSystem/atoms/formElements/FgSelect'; +import FgFormField from '@/components/designSystem/molecules/FgFormField'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import { + API_SCOPES, + useCreateApiTokenMutation +} from '@/queries/apiTokenQueries'; +import type { CreateTokenResult } from '@/queries/apiTokenQueries'; + +const EXPIRY_OPTIONS = [ + { days: 30, label: '30 days' }, + { days: 90, label: '90 days' }, + { days: 365, label: '365 days' } +]; + +type CreateTokenDialogProps = { + readonly showDialog: boolean; + readonly setShowDialog: Dispatch>; + readonly onTokenCreated: (result: CreateTokenResult) => void; +}; + +export default function CreateTokenDialog({ + showDialog, + setShowDialog, + onTokenCreated +}: CreateTokenDialogProps) { + const [name, setName] = useState(''); + const [scopes, setScopes] = useState(['files:read']); + const [expiryDays, setExpiryDays] = useState(30); + const createToken = useCreateApiTokenMutation(); + + const canSubmit = name.trim().length > 0 && scopes.length > 0; + + const toggleScope = (scope: string) => { + setScopes(current => + current.includes(scope) + ? current.filter(s => s !== scope) + : [...current, scope] + ); + }; + + const handleClose = () => { + setShowDialog(false); + setName(''); + setScopes(['files:read']); + setExpiryDays(30); + }; + + const handleSubmit = async () => { + try { + const result = await createToken.mutateAsync({ + name: name.trim(), + scopes, + expires_in_days: expiryDays + }); + handleClose(); + onTokenCreated(result); + } catch { + // createToken.error below surfaces the failure to the user + } + }; + + return ( + + + New API Token + + + + setName(event.target.value)} + placeholder="laptop notebook" + type="text" + value={name} + /> + + +
+ + Scopes + +
+ {API_SCOPES.map(scope => ( + toggleScope(scope)} + /> + ))} +
+ + A :write scope also grants :read. + +
+ + + setExpiryDays(Number(event.target.value))} + value={expiryDays} + > + {EXPIRY_OPTIONS.map(option => ( + + ))} + + + + {createToken.error ? ( + + {createToken.error.message} + + ) : null} + +
+ + Create + + + Cancel + +
+
+ ); +} diff --git a/frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx new file mode 100644 index 000000000..ce47c35d2 --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/NewTokenDialog.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Typography } from '@material-tailwind/react'; +import { + HiOutlineClipboardCopy, + HiOutlineExclamation, + HiOutlineCheck +} from 'react-icons/hi'; +import toast from 'react-hot-toast'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgIcon from '@/components/designSystem/atoms/FgIcon'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import type { CreateTokenResult } from '@/queries/apiTokenQueries'; + +type NewTokenDialogProps = { + readonly result: CreateTokenResult | null; + readonly onClose: () => void; +}; + +export default function NewTokenDialog({ + result, + onClose +}: NewTokenDialogProps) { + const [copied, setCopied] = useState(false); + + if (!result) { + return null; + } + + const snippet = `export FILEGLANCER_URL=${window.location.origin}\nexport FILEGLANCER_TOKEN=${result.secret}`; + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(snippet); + setCopied(true); + toast.success('Token copied to clipboard'); + } catch { + toast.error('Failed to copy to clipboard'); + } + }; + + const handleClose = () => { + setCopied(false); + onClose(); + }; + + return ( + +
+ + + Token created + +
+ +
+ + Copy this now. It will not be shown again. + + + The token secret is not stored anywhere. Save the two lines below + somewhere safe, such as your shell profile or a .env{' '} + file. + +
+ +
+        {snippet}
+      
+ +
+ + Copy + + + Done + +
+
+ ); +} diff --git a/frontend/src/components/ui/Navbar/ProfileMenu.tsx b/frontend/src/components/ui/Navbar/ProfileMenu.tsx index b1d8a3cc2..527f9ed37 100644 --- a/frontend/src/components/ui/Navbar/ProfileMenu.tsx +++ b/frontend/src/components/ui/Navbar/ProfileMenu.tsx @@ -3,7 +3,8 @@ import { HiOutlineLogout, HiOutlineUserCircle, HiOutlineBell, - HiOutlineKey + HiOutlineKey, + HiOutlineCode } from 'react-icons/hi'; import { HiOutlineAdjustmentsHorizontal } from 'react-icons/hi2'; import { Link } from 'react-router'; @@ -79,6 +80,14 @@ export default function ProfileMenu() { SSH Keys ) : null} + + + API Tokens + Date: Mon, 24 Aug 2026 20:43:40 -0400 Subject: [PATCH 19/43] fix: guard create-dialog reopen/cancel and require revoke confirmation Review round 1 fixes: reset the create-token mutation on close so a stale error/secret doesn't reappear on reopen, ignore close attempts while a create is in flight so cancel can't race the response into showing the secret dialog, add a RevokeTokenDialog confirmation step before a token is actually revoked, and scope the revoke-pending state to the specific token being revoked instead of disabling every card. Co-Authored-By: Claude Opus 5 (1M context) --- .../componentTests/ApiTokens.test.tsx | 70 +++++++++++++++++-- frontend/src/components/ApiTokens.tsx | 32 ++++++++- .../components/ui/ApiTokens/ApiTokenCard.tsx | 4 +- .../ui/ApiTokens/CreateTokenDialog.tsx | 14 +++- .../ui/ApiTokens/RevokeTokenDialog.tsx | 49 +++++++++++++ 5 files changed, 158 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx diff --git a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx index 2e3011ecf..a70053eb4 100644 --- a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx +++ b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx @@ -50,7 +50,8 @@ beforeEach(() => { mockUseCreateApiTokenMutation.mockReturnValue({ mutateAsync: mockCreateMutateAsync, isPending: false, - error: null + error: null, + reset: vi.fn() }); mockUseDeleteApiTokenMutation.mockReturnValue({ mutate: mockDeleteMutate, @@ -77,15 +78,76 @@ describe('ApiTokens page', () => { expect(screen.getByText('files:read, files:write')).toBeInTheDocument(); }); - it('calls the delete mutation with the token id when Revoke is clicked', async () => { + it('asks for confirmation before revoking, then calls the delete mutation with the token id', async () => { + setTokens([existingToken]); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Revoke' })); + + // Revoking is destructive and irreversible - it must not happen on the + // first click. + expect(mockDeleteMutate).not.toHaveBeenCalled(); + expect(screen.getByText(/revoke api token/i)).toBeInTheDocument(); + // The confirmation copy names the token (in addition to the card above). + expect(screen.getAllByText('laptop notebook')).toHaveLength(2); + + await user.click(screen.getByRole('button', { name: 'Revoke Token' })); + + expect(mockDeleteMutate).toHaveBeenCalledTimes(1); + expect(mockDeleteMutate.mock.calls[0][0]).toBe('tok_123'); + }); + + it('disables Create when the name is whitespace-only', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.type(screen.getByLabelText('Name'), ' '); + + expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled(); + }); + + it('disables Create when no scopes are selected', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.type(screen.getByLabelText('Name'), 'valid name'); + // Uncheck the default files:read scope, leaving none selected. + await user.click(screen.getByLabelText('files:read')); + + expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled(); + }); + + it('keeps the dialog open and shows the error on a failed create, without opening the secret dialog', async () => { setTokens([existingToken]); + const failure = new Error('token limit reached'); + mockCreateMutateAsync.mockRejectedValue(failure); + mockUseCreateApiTokenMutation.mockReturnValue({ + mutateAsync: mockCreateMutateAsync, + isPending: false, + error: failure, + reset: vi.fn() + }); const user = userEvent.setup(); render(); - await user.click(screen.getByRole('button', { name: /revoke/i })); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.type(screen.getByLabelText('Name'), 'new token'); + await user.click(screen.getByRole('button', { name: /^create$/i })); - expect(mockDeleteMutate).toHaveBeenCalledWith('tok_123'); + expect(await screen.findByText('token limit reached')).toBeInTheDocument(); + // The create dialog is still open - its Name field is still present. + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + // The one-time secret dialog never appeared. + expect(screen.queryByText(/token created/i)).not.toBeInTheDocument(); }); it('shows the secret exactly once after creating a token, and the listing never renders it', async () => { diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx index f550bf8e2..7d5ab37fa 100644 --- a/frontend/src/components/ApiTokens.tsx +++ b/frontend/src/components/ApiTokens.tsx @@ -7,19 +7,33 @@ import FgIcon from '@/components/designSystem/atoms/FgIcon'; import ApiTokenCard from '@/components/ui/ApiTokens/ApiTokenCard'; import CreateTokenDialog from '@/components/ui/ApiTokens/CreateTokenDialog'; import NewTokenDialog from '@/components/ui/ApiTokens/NewTokenDialog'; +import RevokeTokenDialog from '@/components/ui/ApiTokens/RevokeTokenDialog'; import { Spinner } from '@/components/ui/widgets/Loaders'; import { useApiTokensQuery, useDeleteApiTokenMutation } from '@/queries/apiTokenQueries'; -import type { CreateTokenResult } from '@/queries/apiTokenQueries'; +import type { + ApiTokenInfo, + CreateTokenResult +} from '@/queries/apiTokenQueries'; export default function ApiTokens() { const [showCreateDialog, setShowCreateDialog] = useState(false); const [newToken, setNewToken] = useState(null); + const [pendingRevoke, setPendingRevoke] = useState(null); const { data, isLoading, error, refetch } = useApiTokensQuery(); const deleteToken = useDeleteApiTokenMutation(); + const handleConfirmRevoke = () => { + if (!pendingRevoke) { + return; + } + deleteToken.mutate(pendingRevoke.token_id, { + onSuccess: () => setPendingRevoke(null) + }); + }; + const tokens = data ?? []; const hasTokens = tokens.length > 0; @@ -90,9 +104,13 @@ export default function ApiTokens() {
{tokens.map(token => ( deleteToken.mutate(id)} + onRevoke={setPendingRevoke} token={token} /> ))} @@ -107,6 +125,14 @@ export default function ApiTokens() { /> setNewToken(null)} result={newToken} /> + + setPendingRevoke(null)} + onConfirm={handleConfirmRevoke} + open={pendingRevoke !== null} + tokenName={pendingRevoke?.name ?? ''} + /> ); } diff --git a/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx index 4da851915..b303ac8b6 100644 --- a/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx +++ b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx @@ -17,7 +17,7 @@ export default function ApiTokenCard({ isRevoking }: { readonly token: ApiTokenInfo; - readonly onRevoke: (tokenId: string) => void; + readonly onRevoke: (token: ApiTokenInfo) => void; readonly isRevoking: boolean; }) { const isExpired = new Date(token.expires_at) < new Date(); @@ -46,7 +46,7 @@ export default function ApiTokenCard({ onRevoke(token.token_id)} + onClick={() => onRevoke(token)} size="sm" > Revoke diff --git a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx index c4aa977fd..6f02ab2ee 100644 --- a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -47,10 +47,16 @@ export default function CreateTokenDialog({ }; const handleClose = () => { - setShowDialog(false); + if (createToken.isPending) { + // A create is already on the wire; letting the dialog close here would + // still pop the one-time secret dialog when the response lands. + return; + } setName(''); setScopes(['files:read']); setExpiryDays(30); + createToken.reset(); + setShowDialog(false); }; const handleSubmit = async () => { @@ -129,7 +135,11 @@ export default function CreateTokenDialog({ > Create - + Cancel
diff --git a/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx new file mode 100644 index 000000000..f83a5aa26 --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx @@ -0,0 +1,49 @@ +import { Typography } from '@material-tailwind/react'; +import { HiOutlineTrash } from 'react-icons/hi'; + +import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; + +type RevokeTokenDialogProps = { + readonly open: boolean; + readonly tokenName: string; + readonly isPending: boolean; + readonly onClose: () => void; + readonly onConfirm: () => void; +}; + +export default function RevokeTokenDialog({ + open, + tokenName, + isPending, + onClose, + onConfirm +}: RevokeTokenDialogProps) { + return ( + + + Revoke API Token + + + Are you sure you want to revoke {tokenName}? Any script + or notebook using this token will stop working immediately. This cannot + be undone. + +
+ + Keep token + + + Revoke Token + +
+
+ ); +} From b17175c15ed0c8cd2961feeb5d584253a7c7fa6c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 20:59:38 -0400 Subject: [PATCH 20/43] test: add end-to-end spec for API token creation and revocation Covers the full user journey on the API Tokens page: create a token, confirm the secret is shown exactly once and never appears again, then revoke it through the confirmation dialog. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/ui-tests/tests/api-tokens.spec.ts | 62 ++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 frontend/ui-tests/tests/api-tokens.spec.ts diff --git a/frontend/ui-tests/tests/api-tokens.spec.ts b/frontend/ui-tests/tests/api-tokens.spec.ts new file mode 100644 index 000000000..a85605d89 --- /dev/null +++ b/frontend/ui-tests/tests/api-tokens.spec.ts @@ -0,0 +1,62 @@ +import { expect, test } from '../fixtures/fileglancer-fixture'; + +test.describe('API tokens', () => { + test('create, display once, and revoke a token', async ({ + fileglancerPage: page + }) => { + const tokenName = `e2e token ${Date.now()}`; + + await page.goto('/api-tokens', { waitUntil: 'domcontentloaded' }); + + await expect( + page.getByRole('heading', { name: 'API Tokens' }) + ).toBeVisible(); + + await page.getByRole('button', { name: 'New Token' }).click(); + await page.getByPlaceholder('laptop notebook').fill(tokenName); + await page.getByRole('button', { name: 'Create' }).click(); + + // The secret is shown exactly once, in the confirmation dialog. + await expect(page.getByText('Copy this now')).toBeVisible(); + await expect(page.getByText(/FILEGLANCER_TOKEN=fgt_/)).toBeVisible(); + + // Capture the exact secret so we can prove it never appears again. + const snippetText = await page.locator('pre').innerText(); + const secretMatch = snippetText.match(/FILEGLANCER_TOKEN=(fgt_\S+)/); + expect(secretMatch).not.toBeNull(); + const secret = secretMatch![1]; + + await page.getByRole('button', { name: 'Done' }).click(); + + // The listing shows the token but never its secret. Assert on the exact + // captured secret string (not a pattern) so this doesn't accidentally + // match the token's public id, which does appear in the DOM. + await expect(page.getByText(tokenName)).toBeVisible(); + await expect(page.locator('body')).not.toContainText(secret); + + // Revoking requires an explicit confirmation step: clicking the card's + // "Revoke" button only opens the dialog, it does not revoke yet. + const tokenCard = page + .locator('[data-testid="api-token-list"] > *') + .filter({ hasText: tokenName }); + await tokenCard.getByRole('button', { name: 'Revoke', exact: true }).click(); + + await expect( + page.getByRole('heading', { name: 'Revoke API Token' }) + ).toBeVisible(); + await expect(page.locator('strong').filter({ hasText: tokenName })).toBeVisible(); + + await page.getByRole('button', { name: 'Revoke Token' }).click(); + + // Wait for the confirmation dialog to fully close before checking the + // list, so its {tokenName} can't double-match below. + await expect( + page.getByRole('heading', { name: 'Revoke API Token' }) + ).not.toBeVisible(); + + // The token is gone from the list once the revocation is confirmed. + await expect( + page.getByTestId('api-token-list').getByText(tokenName) + ).not.toBeVisible(); + }); +}); From 4b06a68032201801b108822bc12f73de7c944189 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 21:01:28 -0400 Subject: [PATCH 21/43] test: clear api_tokens table in E2E database cleanup api_tokens is per-test-user state of the same class as user_preferences but was missing from cleanDatabase's table list, letting tokens leak across local E2E runs (the webServer config reuses a fixed sqlite path). Co-Authored-By: Claude Opus 5 (1M context) --- frontend/ui-tests/utils/db-cleanup.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/ui-tests/utils/db-cleanup.ts b/frontend/ui-tests/utils/db-cleanup.ts index 22a80f64b..109ec72a4 100644 --- a/frontend/ui-tests/utils/db-cleanup.ts +++ b/frontend/ui-tests/utils/db-cleanup.ts @@ -19,7 +19,7 @@ export function cleanDatabase(testTempDir: string): void { try { db = new Database(dbPath, { fileMustExist: true }); - const tables = ['proxied_paths', 'tickets', 'user_preferences']; + const tables = ['proxied_paths', 'tickets', 'user_preferences', 'api_tokens']; for (const table of tables) { try { From 152ff030aad901c67bab161682611bb43c9b3132 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 21:03:32 -0400 Subject: [PATCH 22/43] style: run prettier on api-tokens spec and db-cleanup util Whitespace-only reformat (multi-line locator chains, wrapped tables array); no logic changes. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/ui-tests/tests/api-tokens.spec.ts | 8 ++++++-- frontend/ui-tests/utils/db-cleanup.ts | 7 ++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/ui-tests/tests/api-tokens.spec.ts b/frontend/ui-tests/tests/api-tokens.spec.ts index a85605d89..38953bdd7 100644 --- a/frontend/ui-tests/tests/api-tokens.spec.ts +++ b/frontend/ui-tests/tests/api-tokens.spec.ts @@ -39,12 +39,16 @@ test.describe('API tokens', () => { const tokenCard = page .locator('[data-testid="api-token-list"] > *') .filter({ hasText: tokenName }); - await tokenCard.getByRole('button', { name: 'Revoke', exact: true }).click(); + await tokenCard + .getByRole('button', { name: 'Revoke', exact: true }) + .click(); await expect( page.getByRole('heading', { name: 'Revoke API Token' }) ).toBeVisible(); - await expect(page.locator('strong').filter({ hasText: tokenName })).toBeVisible(); + await expect( + page.locator('strong').filter({ hasText: tokenName }) + ).toBeVisible(); await page.getByRole('button', { name: 'Revoke Token' }).click(); diff --git a/frontend/ui-tests/utils/db-cleanup.ts b/frontend/ui-tests/utils/db-cleanup.ts index 109ec72a4..3b8fd6f3f 100644 --- a/frontend/ui-tests/utils/db-cleanup.ts +++ b/frontend/ui-tests/utils/db-cleanup.ts @@ -19,7 +19,12 @@ export function cleanDatabase(testTempDir: string): void { try { db = new Database(dbPath, { fileMustExist: true }); - const tables = ['proxied_paths', 'tickets', 'user_preferences', 'api_tokens']; + const tables = [ + 'proxied_paths', + 'tickets', + 'user_preferences', + 'api_tokens' + ]; for (const table of tables) { try { From 7959b4194241db6578673622612adef07fb23dbf Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 21:08:37 -0400 Subject: [PATCH 23/43] test: assert token survives the first Revoke click Directly prove the confirmation gate's property: clicking the card's Revoke button opens the dialog but does not itself remove the token, before the Revoke Token click confirms and removes it. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/ui-tests/tests/api-tokens.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/ui-tests/tests/api-tokens.spec.ts b/frontend/ui-tests/tests/api-tokens.spec.ts index 38953bdd7..1bbbf21fd 100644 --- a/frontend/ui-tests/tests/api-tokens.spec.ts +++ b/frontend/ui-tests/tests/api-tokens.spec.ts @@ -50,6 +50,9 @@ test.describe('API tokens', () => { page.locator('strong').filter({ hasText: tokenName }) ).toBeVisible(); + // The first click must open the dialog WITHOUT revoking anything. + await expect(tokenCard.getByText(tokenName)).toBeVisible(); + await page.getByRole('button', { name: 'Revoke Token' }).click(); // Wait for the confirmation dialog to fully close before checking the From 4001d5bdeb3886f5abcdf3092fcf935b77985b4c Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 21:14:17 -0400 Subject: [PATCH 24/43] docs: use a neutral path in the client module docstring /nearline/... is a real site storage-tier name (checked for in StartTour.tsx), not a generic example. Swap for /data/alice/... in the user-facing help(fileglancer.client) docstring. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index 72d728ab7..f449b8d8f 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -7,7 +7,7 @@ from fileglancer import Fileglancer fg = Fileglancer() # reads FILEGLANCER_URL and FILEGLANCER_TOKEN - link = fg.create_data_link("/nearline/alice/sample.zarr") + link = fg.create_data_link("/data/alice/sample.zarr") """ import os from typing import Any, Dict, List, Optional, Tuple From a20594dff950b2e39686930a499785b277d32827 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Mon, 24 Aug 2026 21:37:50 -0400 Subject: [PATCH 25/43] fix: final review pass on API tokens branch Six fixes from the whole-branch review before merge: - Serialize API token timestamps with a timezone (_ensure_utc, now module-level) so the frontend "Expired" badge isn't wrong outside UTC; ApiTokenCard now reuses formatDateOnly instead of a duplicate local helper. - Note in the create-token dialog and docs that jobs:write runs code on the user's behalf and therefore has the same file access they do, not the containment the scope list implies. - Drop /api/auth/status and /api/cluster-defaults from the scope tables in auth.py - both are unauthenticated routes with no Depends(get_current_user), so required_scope is never consulted for them. - Correct a test_client_paths.py docstring that claimed a TypeScript fixture-sharing arrangement that doesn't exist. - _absolutize in client.py now returns a data link unmodified (path left FSP-relative) when its file share has disappeared from file_share_paths, instead of raising and breaking the whole listing. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/auth.py | 3 +- fileglancer/client.py | 13 ++++++-- fileglancer/server.py | 33 ++++++++++--------- .../componentTests/ApiTokens.test.tsx | 8 +++-- .../components/ui/ApiTokens/ApiTokenCard.tsx | 14 +++----- .../ui/ApiTokens/CreateTokenDialog.tsx | 4 ++- tests/test_api_scopes.py | 3 +- tests/test_api_token_endpoints.py | 15 +++++++++ tests/test_client_links.py | 29 +++++++++++++++- tests/test_client_paths.py | 7 ++-- 10 files changed, 90 insertions(+), 39 deletions(-) diff --git a/fileglancer/auth.py b/fileglancer/auth.py index edffa38df..be9a998b5 100644 --- a/fileglancer/auth.py +++ b/fileglancer/auth.py @@ -245,11 +245,10 @@ def delete_session_cookie(response: Response, settings: Settings): ("/api/proxied-path", "links"), ("/api/neuroglancer", "links"), ("/api/jobs", "jobs"), - ("/api/cluster-defaults", "jobs"), ) # Readable by any valid token regardless of scope: identity and liveness. -_ANY_SCOPE_PATHS = ("/api/profile", "/api/auth/status") +_ANY_SCOPE_PATHS = ("/api/profile",) _READ_METHODS = ("GET", "HEAD") diff --git a/fileglancer/client.py b/fileglancer/client.py index f449b8d8f..77b06b190 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -228,9 +228,18 @@ def _absolutize(self, link: ProxiedPath) -> ProxiedPath: The REST API defines ProxiedPath.path as relative to the file share. This client presents absolute paths throughout, so the value is replaced here. fsp_name is left in place for reference. + + The file-share table is maintained by an external process, so a + share can legitimately disappear out from under an existing link. + When that happens, return the link with its path left FSP-relative + rather than raising - one stale link shouldn't make the rest of + data_links() unusable. """ - return link.model_copy( - update={"path": self.abspath(link.fsp_name, link.path)}) + try: + abs_path = self.abspath(link.fsp_name, link.path) + except FileglancerError: + return link + return link.model_copy(update={"path": abs_path}) def create_data_link(self, path: str, url_prefix: Optional[str] = None) -> ProxiedPath: diff --git a/fileglancer/server.py b/fileglancer/server.py index 373edb2f3..a9aca1c0b 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -267,6 +267,20 @@ def _convert_ticket(db_ticket: db.TicketDB) -> Ticket: ) +def _ensure_utc(dt: Optional[datetime]) -> Optional[datetime]: + """Re-attach UTC timezone to naive datetimes from the DB. + + SQLAlchemy's DateTime column strips tzinfo, so datetimes come back + naive even though they were stored as UTC. Re-attaching ensures + Pydantic serializes with '+00:00' so JS parses them correctly. + """ + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=UTC) + return dt + + def _convert_api_token(row: db.ApiTokenDB) -> ApiTokenInfo: """Convert an ApiTokenDB row to the public ApiTokenInfo model. @@ -277,9 +291,9 @@ def _convert_api_token(row: db.ApiTokenDB) -> ApiTokenInfo: token_id=row.token_id, name=row.name, scopes=row.scopes.split(), - created_at=row.created_at, - expires_at=row.expires_at, - last_used_at=row.last_used_at, + created_at=_ensure_utc(row.created_at), + expires_at=_ensure_utc(row.expires_at), + last_used_at=_ensure_utc(row.last_used_at), ) @@ -2639,19 +2653,6 @@ async def get_job_file(job_id: int, except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - def _ensure_utc(dt: Optional[datetime]) -> Optional[datetime]: - """Re-attach UTC timezone to naive datetimes from the DB. - - SQLAlchemy's DateTime column strips tzinfo, so datetimes come back - naive even though they were stored as UTC. Re-attaching ensures - Pydantic serializes with '+00:00' so JS parses them correctly. - """ - if dt is None: - return None - if dt.tzinfo is None: - return dt.replace(tzinfo=UTC) - return dt - def _convert_job(db_job: db.JobDB, service_url: str = None, files: dict = None, phase: str = None) -> Job: """Convert a database JobDB to a Pydantic Job model. diff --git a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx index a70053eb4..dca9e3046 100644 --- a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx +++ b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx @@ -24,12 +24,16 @@ vi.mock('@/queries/apiTokenQueries', async () => { }; }); +// Naive (no offset) on purpose, unlike the other fixtures in this file: a +// SQLAlchemy DateTime column serializes without a timezone unless something +// re-attaches one. This exercises formatDateOnly's Z-append fallback so a +// regression here (e.g. a missed _ensure_utc call) is actually caught. const existingToken: ApiTokenInfo = { token_id: 'tok_123', name: 'laptop notebook', scopes: ['files:read', 'files:write'], - created_at: '2026-01-01T00:00:00Z', - expires_at: '2027-01-01T00:00:00Z', + created_at: '2026-01-01T00:00:00', + expires_at: '2027-01-01T00:00:00', last_used_at: null }; diff --git a/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx index b303ac8b6..d63235ddc 100644 --- a/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx +++ b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx @@ -2,15 +2,9 @@ import { Card, Typography } from '@material-tailwind/react'; import { HiOutlineTrash } from 'react-icons/hi'; import FgButton from '@/components/designSystem/atoms/FgButton'; +import { formatDateOnly } from '@/utils'; import type { ApiTokenInfo } from '@/queries/apiTokenQueries'; -function formatDate(value: string | null): string { - if (!value) { - return 'Never'; - } - return new Date(value).toLocaleDateString(); -} - export default function ApiTokenCard({ token, onRevoke, @@ -38,9 +32,9 @@ export default function ApiTokenCard({ {token.scopes.join(', ')} - Created {formatDate(token.created_at)} · Expires{' '} - {formatDate(token.expires_at)} · Last used{' '} - {formatDate(token.last_used_at)} + Created {formatDateOnly(token.created_at)} · Expires{' '} + {formatDateOnly(token.expires_at)} · Last used{' '} + {token.last_used_at ? formatDateOnly(token.last_used_at) : 'Never'} - A :write scope also grants :read. + A :write scope also grants :read.{' '} + jobs:write runs code on your behalf, so it has the same + access to your files as you do. diff --git a/tests/test_api_scopes.py b/tests/test_api_scopes.py index 1f845eee7..71ca92da9 100644 --- a/tests/test_api_scopes.py +++ b/tests/test_api_scopes.py @@ -37,13 +37,12 @@ def test_api_scopes_are_exactly_the_six_documented_names(): ("/api/jobs", "GET", "jobs:read"), ("/api/jobs", "POST", "jobs:write"), ("/api/jobs/12/cancel", "POST", "jobs:write"), - ("/api/cluster-defaults", "GET", "jobs:read"), ]) def test_required_scope_maps_path_and_method(path, method, expected): assert required_scope(path, method) == expected -@pytest.mark.parametrize("path", ["/api/profile", "/api/auth/status"]) +@pytest.mark.parametrize("path", ["/api/profile"]) def test_paths_readable_by_any_valid_token(path): assert required_scope(path, "GET") == ANY_SCOPE diff --git a/tests/test_api_token_endpoints.py b/tests/test_api_token_endpoints.py index 46c981d42..3f46389bb 100644 --- a/tests/test_api_token_endpoints.py +++ b/tests/test_api_token_endpoints.py @@ -100,3 +100,18 @@ def test_create_strips_surrounding_whitespace_from_name(test_client): assert response.status_code == 201 assert response.json()["token"]["name"] == "laptop" + + +def test_serialized_timestamps_carry_a_timezone(test_client): + """expires_at (and friends) must not be naive, or JS parses them as local + time and gets the "Expired" badge wrong outside UTC.""" + response = test_client.post("/api/tokens", json={ + "name": "laptop", "scopes": ["files:read"], "expires_in_days": 30, + }) + + token = response.json()["token"] + for field in ("created_at", "expires_at"): + value = token[field] + assert value.endswith("Z") or value[-6] in "+-", ( + f"{field}={value!r} has no timezone offset" + ) diff --git a/tests/test_client_links.py b/tests/test_client_links.py index db3775e87..aab9d3d9e 100644 --- a/tests/test_client_links.py +++ b/tests/test_client_links.py @@ -5,7 +5,11 @@ from fastapi.testclient import TestClient from fileglancer.client import NEUROGLANCER_URL, Fileglancer, FileglancerError -from fileglancer.database import create_api_token, get_file_share_paths +from fileglancer.database import ( + ProxiedPathDB, + create_api_token, + get_file_share_paths, +) from test_api_token_auth import token_app # noqa: F401 @@ -65,6 +69,29 @@ def test_list_data_links_reports_absolute_paths(fg, shared_dir): assert [link.path for link in links] == [shared_dir] +def test_data_links_lists_the_rest_when_one_link_has_a_stale_share(fg, shared_dir, token_app): + """A link whose file share has since disappeared from the table (e.g. + the external process that maintains it removed the entry) must not make + the rest of the listing raise. + + Bypasses create_proxied_path/create_data_link (which validate the share + exists) to plant a row directly, simulating a share that was valid when + the link was made but has since been removed from file_share_paths. + """ + _, db_session = token_app + real = fg.create_data_link(shared_dir) + db_session.add(ProxiedPathDB( + username="alice", sharing_key="stale", sharing_name="stale", + fsp_name="ghost-share", path="some/relative/path")) + db_session.commit() + + links = fg.data_links() + + assert {link.fsp_name for link in links} == {real.fsp_name, "ghost-share"} + stale_link = next(link for link in links if link.fsp_name == "ghost-share") + assert stale_link.path == "some/relative/path" + + def test_get_data_link_by_sharing_key(fg, shared_dir): created = fg.create_data_link(shared_dir) diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py index ec6d508bd..7f2a2204f 100644 --- a/tests/test_client_paths.py +++ b/tests/test_client_paths.py @@ -1,8 +1,9 @@ """Tests for client construction and absolute-path resolution. -Resolution mirrors resolvePathToFsp in frontend/src/utils/pathHandling.ts. -The FSP fixture below is the shared fixture set both resolvers are checked -against; keep it in sync with the TypeScript test. +resolvePathToFsp in frontend/src/utils/pathHandling.ts is the reference +implementation this mirrors. It has its own separate test suite +(frontend/src/__tests__/unitTests/pathHandling.test.ts); the fixtures below +are not shared with it. """ import httpx import pytest From ab48572a85cff6fa5b87305dc54a36b2c4f568f1 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 09:20:08 -0400 Subject: [PATCH 26/43] feat: describe each token scope and warn on the dangerous ones Each scope checkbox now states what it allows, folded into the label so the description is associated with the input for screen readers rather than merely adjacent to it. Descriptions are a total Record over ApiScope, so adding a scope without describing it fails to compile. Selecting files:write or jobs:write surfaces a plain-English warning that the token is password-equivalent: whoever holds it acts as the user, and jobs:write in particular runs arbitrary code and so reaches every file regardless of the file scopes granted. jobs:read is described as reaching full job detail, parameters, environment, and log files, which is what the /api/jobs prefix actually grants -- "List jobs" understated it. Co-Authored-By: Claude Opus 5 (1M context) --- .../componentTests/ApiTokens.test.tsx | 85 ++++++++++++++++++- .../ui/ApiTokens/CreateTokenDialog.tsx | 47 +++++++++- frontend/src/queries/apiTokenQueries.ts | 31 +++++++ 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx index dca9e3046..d95fa8356 100644 --- a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx +++ b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx @@ -18,6 +18,8 @@ vi.mock('@/queries/apiTokenQueries', async () => { >('@/queries/apiTokenQueries'); return { API_SCOPES: actual.API_SCOPES, + SCOPE_DESCRIPTIONS: actual.SCOPE_DESCRIPTIONS, + SCOPE_WARNINGS: actual.SCOPE_WARNINGS, useApiTokensQuery: () => mockUseApiTokensQuery(), useCreateApiTokenMutation: () => mockUseCreateApiTokenMutation(), useDeleteApiTokenMutation: () => mockUseDeleteApiTokenMutation() @@ -103,6 +105,87 @@ describe('ApiTokens page', () => { expect(mockDeleteMutate.mock.calls[0][0]).toBe('tok_123'); }); + it('shows what each scope allows next to its checkbox', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + + expect( + screen.getByLabelText(/^files:read — List directories and read file/) + ).toBeInTheDocument(); + // jobs:read reaches more than the name implies: full job detail and logs. + expect( + screen.getByLabelText( + /^jobs:read .*parameters, environment, and log files/ + ) + ).toBeInTheDocument(); + }); + + it('shows no theft warning when only read scopes are selected', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.click(screen.getByLabelText(/^links:read/)); + + expect(screen.queryByText(/treat this token like a password/i)).toBeNull(); + }); + + it('warns about file access when files:write is selected', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.click(screen.getByLabelText(/^files:write/)); + + expect( + screen.getByText(/treat this token like a password/i) + ).toBeInTheDocument(); + expect( + screen.getByText(/files:write lets them create, change, and delete/) + ).toBeInTheDocument(); + expect(screen.queryByText(/jobs:write lets them run any code/)).toBeNull(); + }); + + it('warns that jobs:write can run code as you, regardless of file scopes', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.click(screen.getByLabelText(/^jobs:write/)); + + expect( + screen.getByText( + /jobs:write lets them run any code on the cluster as you/ + ) + ).toBeInTheDocument(); + expect( + screen.getByText(/even if you did not grant the file scopes/) + ).toBeInTheDocument(); + }); + + it('lists both warnings when files:write and jobs:write are both selected', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.click(screen.getByLabelText(/^files:write/)); + await user.click(screen.getByLabelText(/^jobs:write/)); + + expect( + screen.getByText(/files:write lets them create/) + ).toBeInTheDocument(); + expect( + screen.getByText(/jobs:write lets them run any code/) + ).toBeInTheDocument(); + }); + it('disables Create when the name is whitespace-only', async () => { setTokens([]); const user = userEvent.setup(); @@ -124,7 +207,7 @@ describe('ApiTokens page', () => { await user.click(screen.getByRole('button', { name: /new token/i })); await user.type(screen.getByLabelText('Name'), 'valid name'); // Uncheck the default files:read scope, leaving none selected. - await user.click(screen.getByLabelText('files:read')); + await user.click(screen.getByLabelText(/^files:read/)); expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled(); }); diff --git a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx index 53b3984a4..297358297 100644 --- a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { Typography } from '@material-tailwind/react'; +import { HiOutlineExclamationTriangle } from 'react-icons/hi2'; import FgButton from '@/components/designSystem/atoms/FgButton'; +import FgIcon from '@/components/designSystem/atoms/FgIcon'; import FgCheckbox from '@/components/designSystem/atoms/formElements/FgCheckbox'; import FgInput from '@/components/designSystem/atoms/formElements/FgInput'; import FgSelect from '@/components/designSystem/atoms/formElements/FgSelect'; @@ -10,6 +12,8 @@ import FgFormField from '@/components/designSystem/molecules/FgFormField'; import FgDialog from '@/components/ui/Dialogs/FgDialog'; import { API_SCOPES, + SCOPE_DESCRIPTIONS, + SCOPE_WARNINGS, useCreateApiTokenMutation } from '@/queries/apiTokenQueries'; import type { CreateTokenResult } from '@/queries/apiTokenQueries'; @@ -38,6 +42,13 @@ export default function CreateTokenDialog({ const canSubmit = name.trim().length > 0 && scopes.length > 0; + // Warnings for whichever dangerous scopes are currently selected, iterated in + // API_SCOPES order so the list stays stable as boxes are ticked. + const selectedWarnings = API_SCOPES.flatMap(scope => { + const warning = SCOPE_WARNINGS[scope]; + return warning && scopes.includes(scope) ? [{ scope, warning }] : []; + }); + const toggleScope = (scope: string) => { setScopes(current => current.includes(scope) @@ -97,16 +108,44 @@ export default function CreateTokenDialog({ toggleScope(scope)} /> ))} - A :write scope also grants :read.{' '} - jobs:write runs code on your behalf, so it has the same - access to your files as you do. + A :write scope also grants :read. + + {selectedWarnings.length > 0 ? ( +
+ +
+ + Treat this token like a password. Anyone who obtains it can act + as you — Fileglancer cannot tell your own scripts apart from + someone using a stolen token. + +
    + {selectedWarnings.map(({ scope, warning }) => ( +
  • + {warning} +
  • + ))} +
+ + Store it somewhere only you can read, and revoke it here if you + think it has leaked. + +
+
+ ) : null} diff --git a/frontend/src/queries/apiTokenQueries.ts b/frontend/src/queries/apiTokenQueries.ts index f55365b98..207e3b9a2 100644 --- a/frontend/src/queries/apiTokenQueries.ts +++ b/frontend/src/queries/apiTokenQueries.ts @@ -22,6 +22,37 @@ export const API_SCOPES = [ export type ApiScope = (typeof API_SCOPES)[number]; +/** + * What each scope allows, shown next to its checkbox when creating a token. + * + * Kept verbatim in sync with the scope table on the Python API docs page. + * Typed as a total Record so adding a scope to API_SCOPES without describing + * it here is a compile error rather than a blank row in the UI. + */ +export const SCOPE_DESCRIPTIONS: Record = { + 'files:read': 'List directories and read file contents', + 'files:write': 'Create, rename, delete, and write files', + 'links:read': 'List data links and Neuroglancer links', + 'links:write': 'Create and delete data links and Neuroglancer links', + 'jobs:read': + "List jobs and read each job's full details, parameters, environment, and log files", + 'jobs:write': 'Submit and cancel jobs' +}; + +/** + * Scopes whose consequences are worse than their name suggests, and the plain + * English explanation shown when one of them is selected. + * + * Only these two are listed: the read scopes and the link scopes cannot be + * used to modify a user's files or run code as them. + */ +export const SCOPE_WARNINGS: Partial> = { + 'files:write': + 'files:write lets them create, change, and delete any file you can reach, including data you never meant to share.', + 'jobs:write': + 'jobs:write lets them run any code on the cluster as you. That includes reading and changing all of your files, even if you did not grant the file scopes.' +}; + /** * An API token, without its secret. */ From 5840e555547344c365cef854414bef252794061b Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 10:04:37 -0400 Subject: [PATCH 27/43] feat: lay out token scopes in two columns and enforce write implies read Widens the create dialog to max-w-4xl (twice max-w-md) for the added text, and lays the scopes out as a single two-column grid: scope name, then a greyed description. One grid spanning every row rather than a grid per row is what aligns the second column, since a CSS grid column is as wide as its widest cell across all rows -- no hard-coded width. Checkboxes are top-aligned so a wrapping description does not drag them down, and the em-dashes are gone now that the columns carry the separation. Selecting a :write scope now checks its :read counterpart and locks it, replacing the "A :write scope also grants :read" note: the server grants read implicitly to any write scope, so an unchecked read box was showing a state the server would not honour. Deselecting write releases the lock and leaves read granted. Also fixes a real bug in FgCheckbox: h-4 w-4 is a flex basis, not a floor, so a flex parent shrank the box to fit a long label -- which rendered the boxes at visibly different sizes across labels of differing length. Adding shrink-0 fixes it for every consumer, not just this dialog. Co-Authored-By: Claude Opus 5 (1M context) --- .../componentTests/ApiTokens.test.tsx | 75 +++++++++++++++--- .../atoms/formElements/FgCheckbox.tsx | 5 +- .../ui/ApiTokens/CreateTokenDialog.tsx | 77 +++++++++++++------ 3 files changed, 123 insertions(+), 34 deletions(-) diff --git a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx index d95fa8356..a2f75a7e3 100644 --- a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx +++ b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx @@ -113,14 +113,69 @@ describe('ApiTokens page', () => { await user.click(screen.getByRole('button', { name: /new token/i })); expect( - screen.getByLabelText(/^files:read — List directories and read file/) + screen.getByText('List directories and read file contents') ).toBeInTheDocument(); // jobs:read reaches more than the name implies: full job detail and logs. expect( - screen.getByLabelText( - /^jobs:read .*parameters, environment, and log files/ - ) + screen.getByText(/parameters, environment, and log files/) ).toBeInTheDocument(); + // The description is associated with its checkbox, not merely adjacent. + expect(screen.getByLabelText('files:read')).toHaveAttribute( + 'aria-describedby', + 'scope-files:read-description' + ); + }); + + it('checks and locks the matching read scope when a write scope is selected', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + // Clear the default files:read so it cannot mask the implication. + await user.click(screen.getByLabelText('files:read')); + expect(screen.getByLabelText('files:read')).not.toBeChecked(); + + await user.click(screen.getByLabelText('jobs:write')); + + expect(screen.getByLabelText('jobs:read')).toBeChecked(); + expect(screen.getByLabelText('jobs:read')).toBeDisabled(); + // Unrelated read scopes are untouched. + expect(screen.getByLabelText('files:read')).not.toBeChecked(); + }); + + it('releases the read scope when its write scope is deselected', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.click(screen.getByLabelText('links:write')); + expect(screen.getByLabelText('links:read')).toBeDisabled(); + + await user.click(screen.getByLabelText('links:write')); + + // Still granted, but the user can uncheck it again. + expect(screen.getByLabelText('links:read')).toBeChecked(); + expect(screen.getByLabelText('links:read')).toBeEnabled(); + }); + + it('submits the implied read scope alongside the write scope', async () => { + setTokens([]); + mockCreateMutateAsync.mockResolvedValue({ + token: { ...existingToken, scopes: ['files:read', 'files:write'] }, + secret: 'fgt_tok_123_supersecret' + }); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.type(screen.getByLabelText('Name'), 'writer'); + await user.click(screen.getByLabelText('files:write')); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + const sent = mockCreateMutateAsync.mock.calls[0][0]; + expect([...sent.scopes].sort()).toEqual(['files:read', 'files:write']); }); it('shows no theft warning when only read scopes are selected', async () => { @@ -129,7 +184,7 @@ describe('ApiTokens page', () => { render(); await user.click(screen.getByRole('button', { name: /new token/i })); - await user.click(screen.getByLabelText(/^links:read/)); + await user.click(screen.getByLabelText('links:read')); expect(screen.queryByText(/treat this token like a password/i)).toBeNull(); }); @@ -140,7 +195,7 @@ describe('ApiTokens page', () => { render(); await user.click(screen.getByRole('button', { name: /new token/i })); - await user.click(screen.getByLabelText(/^files:write/)); + await user.click(screen.getByLabelText('files:write')); expect( screen.getByText(/treat this token like a password/i) @@ -157,7 +212,7 @@ describe('ApiTokens page', () => { render(); await user.click(screen.getByRole('button', { name: /new token/i })); - await user.click(screen.getByLabelText(/^jobs:write/)); + await user.click(screen.getByLabelText('jobs:write')); expect( screen.getByText( @@ -175,8 +230,8 @@ describe('ApiTokens page', () => { render(); await user.click(screen.getByRole('button', { name: /new token/i })); - await user.click(screen.getByLabelText(/^files:write/)); - await user.click(screen.getByLabelText(/^jobs:write/)); + await user.click(screen.getByLabelText('files:write')); + await user.click(screen.getByLabelText('jobs:write')); expect( screen.getByText(/files:write lets them create/) @@ -207,7 +262,7 @@ describe('ApiTokens page', () => { await user.click(screen.getByRole('button', { name: /new token/i })); await user.type(screen.getByLabelText('Name'), 'valid name'); // Uncheck the default files:read scope, leaving none selected. - await user.click(screen.getByLabelText(/^files:read/)); + await user.click(screen.getByLabelText('files:read')); expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled(); }); diff --git a/frontend/src/components/designSystem/atoms/formElements/FgCheckbox.tsx b/frontend/src/components/designSystem/atoms/formElements/FgCheckbox.tsx index a71d07552..da2f883be 100644 --- a/frontend/src/components/designSystem/atoms/formElements/FgCheckbox.tsx +++ b/frontend/src/components/designSystem/atoms/formElements/FgCheckbox.tsx @@ -37,8 +37,11 @@ const FgCheckbox = forwardRef( const disabledClasses = 'disabled:cursor-not-allowed disabled:opacity-50'; + // shrink-0 matters: h-4 w-4 alone is a flex basis, not a floor, so a flex + // parent shrinks the box to make room for a long label. In a list of + // mixed-length labels that renders the boxes at visibly different sizes. const inputClassName = - `h-4 w-4 dark:brightness-90 dark:checked:brightness-100 ${colorClasses} ${FORM_CONTROL_FOCUS_CLASSES} ${disabledClasses} ${className}`.trim(); + `h-4 w-4 shrink-0 dark:brightness-90 dark:checked:brightness-100 ${colorClasses} ${FORM_CONTROL_FOCUS_CLASSES} ${disabledClasses} ${className}`.trim(); return (
diff --git a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx index 297358297..21cab4925 100644 --- a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { Fragment, useState } from 'react'; import type { Dispatch, SetStateAction } from 'react'; import { Typography } from '@material-tailwind/react'; import { HiOutlineExclamationTriangle } from 'react-icons/hi2'; @@ -16,7 +16,7 @@ import { SCOPE_WARNINGS, useCreateApiTokenMutation } from '@/queries/apiTokenQueries'; -import type { CreateTokenResult } from '@/queries/apiTokenQueries'; +import type { ApiScope, CreateTokenResult } from '@/queries/apiTokenQueries'; const EXPIRY_OPTIONS = [ { days: 30, label: '30 days' }, @@ -36,7 +36,7 @@ export default function CreateTokenDialog({ onTokenCreated }: CreateTokenDialogProps) { const [name, setName] = useState(''); - const [scopes, setScopes] = useState(['files:read']); + const [scopes, setScopes] = useState(['files:read']); const [expiryDays, setExpiryDays] = useState(30); const createToken = useCreateApiTokenMutation(); @@ -49,12 +49,32 @@ export default function CreateTokenDialog({ return warning && scopes.includes(scope) ? [{ scope, warning }] : []; }); - const toggleScope = (scope: string) => { - setScopes(current => - current.includes(scope) - ? current.filter(s => s !== scope) - : [...current, scope] + /** The `:read` scope a `:write` scope implies, or null for a read scope. */ + const impliedRead = (scope: ApiScope): ApiScope | null => + scope.endsWith(':write') + ? (scope.replace(':write', ':read') as ApiScope) + : null; + + /** + * A `:read` box is locked on while its `:write` counterpart is selected. The + * server grants read implicitly to any write scope, so letting the user + * uncheck it would show a state the server would not honour. + */ + const isImpliedByWrite = (scope: ApiScope) => + API_SCOPES.some( + other => scopes.includes(other) && impliedRead(other) === scope ); + + const toggleScope = (scope: ApiScope) => { + setScopes(current => { + if (current.includes(scope)) { + return current.filter(s => s !== scope); + } + const read = impliedRead(scope); + return read && !current.includes(read) + ? [...current, scope, read] + : [...current, scope]; + }); }; const handleClose = () => { @@ -85,7 +105,7 @@ export default function CreateTokenDialog({ }; return ( - + New API Token @@ -103,21 +123,32 @@ export default function CreateTokenDialog({ Scopes -
- {API_SCOPES.map(scope => ( - toggleScope(scope)} - /> - ))} + {/* One grid for every row, not a grid per row: a CSS grid column is as + wide as its widest cell across all rows, which is what lines the + descriptions up without hard-coding a width. */} +
+ {API_SCOPES.map(scope => { + const locked = isImpliedByWrite(scope); + const descriptionId = `scope-${scope}-description`; + return ( + + toggleScope(scope)} + /> + + {SCOPE_DESCRIPTIONS[scope]} + + + ); + })}
- - A :write scope also grants :read. - {selectedWarnings.length > 0 ? (
From a1f998168a73a685b8f3f71d649381220357b585 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 10:12:41 -0400 Subject: [PATCH 28/43] feat: add an interactive walkthrough script for the Python client Steps through every public Fileglancer client method, printing what it is about to do and the exact call before running it, then pausing so the result can be verified in the web UI or on disk. Enter runs a step, s skips, q quits. Everything is created inside one scratch directory under the cwd and removed in a finally block, including data links and Neuroglancer links, so quitting partway through or a failing step still cleans up. Includes the error paths as deliberate steps -- an unresolvable path, ls() on a file, a cross-share rename, a missing job -- since those messages are what a user actually meets when something goes wrong. Verified end to end against a live server with an all-scopes token: 24 steps, no residue. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/api_walkthrough.py | 367 +++++++++++++++++++++++++++++++++++++ 1 file changed, 367 insertions(+) create mode 100644 scripts/api_walkthrough.py diff --git a/scripts/api_walkthrough.py b/scripts/api_walkthrough.py new file mode 100644 index 000000000..8a9965088 --- /dev/null +++ b/scripts/api_walkthrough.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +"""Interactive walkthrough of the Fileglancer Python client. + +Steps through every public method on `Fileglancer`, describing what it is about +to do and showing the exact call before running it, then pausing so you can +verify the result in the web UI (or on disk) before continuing. + +All files and directories are created inside a single scratch directory under +the current working directory, and removed at the end -- including if a step +fails or you quit partway through. + +Usage: + + export FILEGLANCER_URL=http://localhost:7878 + export FILEGLANCER_TOKEN=fgt_... + pixi run python scripts/api_walkthrough.py + +Create the token on the API Tokens page of the web UI. Grant it all six +scopes, or expect the correspondingly-scoped steps to fail with a 403 -- which +is itself worth seeing. +""" +import os +import shutil +import sys +import traceback + +import httpx + +from fileglancer import Fileglancer, FileglancerError +from fileglancer.client import NEUROGLANCER_URL + +# ANSI codes, disabled when stdout is not a terminal. +if sys.stdout.isatty(): + BOLD, DIM, GREEN, RED, YELLOW, RESET = ( + "\033[1m", "\033[2m", "\033[32m", "\033[31m", "\033[33m", "\033[0m") +else: + BOLD = DIM = GREEN = RED = YELLOW = RESET = "" + +SCRATCH_NAME = "fg-api-walkthrough" + + +class Quit(Exception): + """Raised when the user chooses to stop early.""" + + +class Runner: + """Prints each step, waits for consent, runs it, and reports the result.""" + + def __init__(self): + self.number = 0 + self.ran = 0 + self.skipped = 0 + self.failed = 0 + + def step(self, description, code, call, verify=None): + """Describe a step, wait for input, then run it. + + Args: + description: what this step does, in plain English. + code: the call as you would type it, shown before running. + call: zero-arg callable performing the step. + verify: optional hint on how to check the result yourself. + """ + self.number += 1 + print(f"\n{BOLD}── Step {self.number}: {description}{RESET}") + print(f" {DIM}{code}{RESET}") + if verify: + print(f" {DIM}verify: {verify}{RESET}") + + answer = input(f" {YELLOW}[Enter] run · s skip · q quit{RESET} ").strip().lower() + if answer == "q": + raise Quit() + if answer == "s": + self.skipped += 1 + print(f" {DIM}skipped{RESET}") + return None + + try: + result = call() + except Exception: + self.failed += 1 + print(f" {RED}FAILED{RESET}") + traceback.print_exc() + return None + + self.ran += 1 + print(f" {GREEN}ok{RESET} → {self._format(result)}") + return result + + @staticmethod + def _format(result): + """Render a result compactly; lists get counted and sampled.""" + if result is None: + return "(no return value)" + if isinstance(result, list): + if not result: + return "[] (empty)" + head = "\n ".join(repr(item) for item in result[:5]) + more = f"\n … and {len(result) - 5} more" if len(result) > 5 else "" + return f"{len(result)} item(s)\n {head}{more}" + return repr(result) + + def summary(self): + print(f"\n{BOLD}── Summary{RESET}") + print(f" ran {self.ran}, skipped {self.skipped}, " + f"{RED if self.failed else ''}failed {self.failed}{RESET}") + + +def preflight(): + """Check configuration and resolve the working directory to a file share.""" + url = os.environ.get("FILEGLANCER_URL") + token = os.environ.get("FILEGLANCER_TOKEN") + missing = [n for n, v in (("FILEGLANCER_URL", url), + ("FILEGLANCER_TOKEN", token)) if not v] + if missing: + sys.exit(f"{RED}Not configured.{RESET} Set {' and '.join(missing)} first. " + f"See the docstring at the top of this file.") + + fg = Fileglancer() + cwd = os.getcwd() + + try: + fsp_name, relative = fg._resolve(cwd) + except httpx.HTTPError as error: + # The client wraps error *responses* in FileglancerError but lets + # transport failures through as-is, so catch those separately rather + # than greeting the user with an httpx traceback. + sys.exit(f"{RED}Could not reach {url}.{RESET}\n{type(error).__name__}: " + f"{error}\n\nIs the server running? Try: pixi run dev-launch") + except FileglancerError as error: + sys.exit(f"{RED}The current directory is not inside a Fileglancer file " + f"share, so the client cannot address it.{RESET}\n{error}\n\n" + f"Re-run this from a directory under one of those mount " + f"points, or start the server with -f {cwd}") + + print(f"{BOLD}Fileglancer Python client walkthrough{RESET}") + print(f" server {url}") + print(f" cwd {cwd}") + print(f" resolves to file share {fsp_name!r}, relative path " + f"{relative or '(share root)'!r}") + return fg, cwd + + +def main(): + fg, cwd = preflight() + scratch = os.path.join(cwd, SCRATCH_NAME) + if os.path.exists(scratch): + sys.exit(f"{RED}{scratch} already exists.{RESET} Remove it and re-run.") + + nested = os.path.join(scratch, "nested") + notes = os.path.join(scratch, "notes.txt") + renamed = os.path.join(scratch, "notes-renamed.txt") + created_links = [] + created_ng_keys = [] + + print(f"\nEverything is created under {BOLD}{scratch}{RESET} and removed at " + f"the end.\nEach step is described before it runs; nothing happens " + f"until you press Enter.") + + r = Runner() + try: + # --- File shares and path resolution --- + r.step("List the file shares this token can see", + "fg.file_share_paths()", + fg.file_share_paths, + verify="should include the share your cwd lives under") + + r.step("Drop the cached share list, forcing a refetch next time", + "fg.refresh()", + fg.refresh) + + r.step("Turn a share name plus relative path back into an absolute path", + f"fg.abspath({fg._resolve(cwd)[0]!r}, {fg._resolve(cwd)[1]!r})", + lambda: fg.abspath(*fg._resolve(cwd)), + verify=f"should equal {cwd}") + + r.step("Ask about a path in no file share (expected to raise)", + "fg._resolve('/definitely/not/a/share')", + lambda: _expect_error(fg._resolve, "/definitely/not/a/share"), + verify="the error should list the available mount points") + + # --- Files --- + r.step("Describe the current directory", + "fg.stat(cwd)", + lambda: fg.stat(cwd), + verify="is_dir should be True") + + r.step("List the current directory", + "fg.ls(cwd)", + lambda: fg.ls(cwd), + verify="should look like `ls` here; each entry has absolute_path") + + r.step("Create a scratch directory", + f"fg.mkdir({SCRATCH_NAME!r})", + lambda: fg.mkdir(scratch), + verify=f"{scratch} should now exist on disk") + + r.step("Create a directory inside it", + f"fg.mkdir({SCRATCH_NAME + '/nested'!r})", + lambda: fg.mkdir(nested)) + + r.step("Write a text file", + f"fg.write({SCRATCH_NAME + '/notes.txt'!r}, b'hello from the API\\n')", + lambda: fg.write(notes, b"hello from the API\n"), + verify="returns the number of bytes written") + + r.step("Read it back", + f"fg.read({SCRATCH_NAME + '/notes.txt'!r})", + lambda: fg.read(notes), + verify="should be b'hello from the API\\n'") + + r.step("List the scratch directory, which now has two entries", + f"fg.ls({SCRATCH_NAME!r})", + lambda: fg.ls(scratch), + verify="nested/ and notes.txt") + + r.step("Call ls() on a file instead of a directory (expected to raise)", + f"fg.ls({SCRATCH_NAME + '/notes.txt'!r})", + lambda: _expect_error(fg.ls, notes), + verify="should say 'Not a directory', not return an empty list") + + r.step("Rename the file", + f"fg.rename({SCRATCH_NAME + '/notes.txt'!r}, {SCRATCH_NAME + '/notes-renamed.txt'!r})", + lambda: fg.rename(notes, renamed), + verify="notes.txt should be gone, notes-renamed.txt present") + + r.step("Try to move it to a different file share (expected to raise)", + f"fg.rename({SCRATCH_NAME + '/notes-renamed.txt'!r}, '/nowhere/x.txt')", + lambda: _expect_error(fg.rename, renamed, "/nowhere/x.txt"), + verify="refused locally, before any HTTP request") + + r.step("Delete the nested directory", + f"fg.delete({SCRATCH_NAME + '/nested'!r})", + lambda: fg.delete(nested), + verify="nested/ should be gone") + + # --- Data links --- + def make_link(): + link = fg.create_data_link(scratch) + created_links.append(link.sharing_key) + return link + + link = r.step("Create a data link for the scratch directory", + f"fg.create_data_link({SCRATCH_NAME!r})", + make_link, + verify="check the Data Links page in the web UI; " + ".path should be absolute") + + r.step("List your data links", + "fg.data_links()", + fg.data_links, + verify="the new link should appear") + + if link is not None: + r.step("Fetch that one link by its sharing key", + f"fg.data_link({link.sharing_key!r})", + lambda: fg.data_link(link.sharing_key)) + + # --- Neuroglancer links --- + def make_ng_link(): + state = {"layers": [], "title": "api walkthrough"} + if link is not None: + state["layers"] = [ + {"type": "image", "name": "sample", + "source": f"zarr://{link.url}"} + ] + url = fg.create_ng_link(state, title="api walkthrough") + for entry in fg.ng_links(): + if entry.title == "api walkthrough": + created_ng_keys.append(entry.short_key) + return url + + r.step("Shorten a Neuroglancer state into a link", + "fg.create_ng_link({'layers': [...]}, title='api walkthrough')", + make_ng_link, + verify=f"returns a {NEUROGLANCER_URL} URL with #! in it; " + f"check the NG Links page") + + r.step("List your Neuroglancer links", + "fg.ng_links()", + fg.ng_links) + + # --- Jobs --- + r.step("List your jobs", + "fg.jobs()", + fg.jobs, + verify="matches the Jobs page; empty list is a valid result") + + r.step("List only running jobs", + "fg.jobs(status='RUNNING')", + lambda: fg.jobs(status="RUNNING")) + + r.step("Fetch a job that does not exist (expected to raise)", + "fg.job(999999)", + lambda: _expect_error(fg.job, 999999), + verify="should be a 404") + + print(f"\n{DIM}The next step really submits a cluster job. It needs an " + f"app registered in your library, and will fail otherwise. Skip " + f"it unless you have one.{RESET}") + r.step("Submit a job (needs a real app; skip if unsure)", + "fg.submit_job(app_url=..., entry_point_id=..., parameters={})", + lambda: _submit_job_interactive(fg), + verify="if it succeeds, cancel it on the Jobs page") + + except Quit: + print(f"\n{YELLOW}Stopping early at your request.{RESET}") + finally: + cleanup(fg, scratch, created_links, created_ng_keys) + r.summary() + + +def _expect_error(func, *args): + """Call something expected to raise, and return the error as the result.""" + try: + func(*args) + except FileglancerError as error: + status = f" (HTTP {error.status_code})" if error.status_code else "" + return f"raised FileglancerError{status}: {error}" + return "NO ERROR RAISED — that is itself a finding" + + +def _submit_job_interactive(fg): + app_url = input(" app_url (blank to skip): ").strip() + if not app_url: + return "skipped, no app_url given" + entry_point = input(" entry_point_id: ").strip() + return fg.submit_job(app_url=app_url, entry_point_id=entry_point) + + +def cleanup(fg, scratch, sharing_keys, ng_keys): + """Remove everything this script created, reporting each removal.""" + print(f"\n{BOLD}── Cleanup{RESET}") + + for key in sharing_keys: + try: + fg.delete_data_link(key) + print(f" removed data link {key}") + except FileglancerError as error: + print(f" {YELLOW}could not remove data link {key}: {error}{RESET}") + + for key in ng_keys: + try: + fg.delete_ng_link(key) + print(f" removed Neuroglancer link {key}") + except FileglancerError as error: + print(f" {YELLOW}could not remove NG link {key}: {error}{RESET}") + + # Local rmtree rather than fg.delete(): the API deletes only empty + # directories, and this is cleanup, not part of what we are demonstrating. + if os.path.exists(scratch): + try: + shutil.rmtree(scratch) + print(f" removed {scratch}") + except OSError as error: + print(f" {YELLOW}could not remove {scratch}: {error}{RESET}") + else: + print(f" nothing to remove at {scratch}") + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + # Cleanup already ran in main's finally block if we got that far. + print("\ninterrupted") + sys.exit(130) From 3fa970f6d11f40ce09affb95c8eaff0286cd0c05 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 10:18:32 -0400 Subject: [PATCH 29/43] fix: say so when the server has no file shares configured _resolve rendered "Available mount points:" with nothing after it when the share list was empty, which reads as though the caller's path were at fault rather than the server having nothing mounted. Reported from a real setup with file_share_mounts: [] in config.yaml. Now distinguishes the two cases and names the setting to change. The walkthrough script checks the same condition up front so it can print the config snippet, and its unreachable-server message mentions the scheme, since a server started with --ssl-keyfile needs https://. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/client.py | 13 +++++++++++-- scripts/api_walkthrough.py | 17 +++++++++++++++++ tests/test_client_paths.py | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index 77b06b190..22a16318d 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -134,8 +134,17 @@ def _resolve(self, path: str) -> Tuple[str, str]: best_fsp, best_prefix = fsp, candidate if best_fsp is None: - mounts = ", ".join(sorted(f.mount_path - for f in self.file_share_paths())) + shares = self.file_share_paths() + if not shares: + # Distinguished from a genuine mismatch because the mismatch + # message would otherwise read "Available mount points:" with + # nothing after it, which sends people looking at their path + # when the server simply has nothing mounted. + raise FileglancerError( + f"Cannot resolve {path!r}: this Fileglancer server has no " + f"file shares configured. Set file_share_mounts in the " + f"server's config.yaml, or start it with -f .") + mounts = ", ".join(sorted(f.mount_path for f in shares)) raise FileglancerError( f"No file share matches {path!r}. Available mount points: {mounts}") diff --git a/scripts/api_walkthrough.py b/scripts/api_walkthrough.py index 8a9965088..7a96f95bb 100644 --- a/scripts/api_walkthrough.py +++ b/scripts/api_walkthrough.py @@ -119,6 +119,23 @@ def preflight(): fg = Fileglancer() cwd = os.getcwd() + try: + shares = fg.file_share_paths() + except httpx.HTTPError as error: + sys.exit(f"{RED}Could not reach {url}.{RESET}\n{type(error).__name__}: " + f"{error}\n\nIs the server running, and is the scheme right? " + f"A server started with --ssl-keyfile needs https://") + + if not shares: + sys.exit(f"{RED}This server has no file shares configured, so the " + f"client cannot address any path.{RESET}\n\n" + f"Set file_share_mounts in the server's config.yaml:\n" + f" file_share_mounts:\n - \"~/\"\n\n" + f"then restart it. Or run a throwaway server over this " + f"directory:\n" + f" pixi run python -m fileglancer.cli start --no-browser " + f"-f {cwd}") + try: fsp_name, relative = fg._resolve(cwd) except httpx.HTTPError as error: diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py index 7f2a2204f..f3dedf873 100644 --- a/tests/test_client_paths.py +++ b/tests/test_client_paths.py @@ -97,6 +97,24 @@ def test_partial_segment_does_not_match(fg): fg._resolve("/nearlinex/data") +def test_error_when_the_server_has_no_shares_says_so(fg): + """An empty share list is a server configuration problem, not a bad path. + + Reported for real: the mismatch message rendered as "Available mount + points:" with nothing after it, which reads as though the path were at + fault when nothing is mounted at all. + """ + fg._fsp_cache = [] + + with pytest.raises(FileglancerError) as excinfo: + fg._resolve("/anything/at/all") + + message = str(excinfo.value) + assert "no file shares configured" in message + assert "file_share_mounts" in message + assert "Available mount points" not in message + + def test_unmatched_path_error_lists_available_mounts(fg): with pytest.raises(FileglancerError) as excinfo: fg._resolve("/not/a/share/at/all") From 04e46dd296babbde6121e946b5f2a84ea1f6d7d8 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 11:10:47 -0400 Subject: [PATCH 30/43] fix: stop enumerating every file share in the no-match error A real deployment mounts hundreds of shares, so listing them all buried the one line the caller needed to read. The message now names the offending path and points at file_share_paths() for discovery, which is a fixed ~100 characters regardless of how many shares exist. The empty-share-list case keeps its own distinct message, since there the server configuration really is the thing to look at. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/client.py | 16 ++++++++-------- scripts/api_walkthrough.py | 6 +++--- tests/test_client_paths.py | 14 ++++++++++---- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index 22a16318d..9d71aa3b3 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -134,19 +134,19 @@ def _resolve(self, path: str) -> Tuple[str, str]: best_fsp, best_prefix = fsp, candidate if best_fsp is None: - shares = self.file_share_paths() - if not shares: - # Distinguished from a genuine mismatch because the mismatch - # message would otherwise read "Available mount points:" with - # nothing after it, which sends people looking at their path - # when the server simply has nothing mounted. + if not self.file_share_paths(): + # Distinguished from a genuine mismatch, which otherwise sends + # people looking at their own path when the server simply has + # nothing mounted. raise FileglancerError( f"Cannot resolve {path!r}: this Fileglancer server has no " f"file shares configured. Set file_share_mounts in the " f"server's config.yaml, or start it with -f .") - mounts = ", ".join(sorted(f.mount_path for f in shares)) + # Deliberately does not enumerate the shares: a real deployment has + # hundreds, which buries the message it is attached to. raise FileglancerError( - f"No file share matches {path!r}. Available mount points: {mounts}") + f"No file share matches {path!r}. " + f"Call file_share_paths() to see the available shares.") return best_fsp.name, normalized[len(best_prefix):].strip("/") diff --git a/scripts/api_walkthrough.py b/scripts/api_walkthrough.py index 7a96f95bb..b45f9109c 100644 --- a/scripts/api_walkthrough.py +++ b/scripts/api_walkthrough.py @@ -147,8 +147,8 @@ def preflight(): except FileglancerError as error: sys.exit(f"{RED}The current directory is not inside a Fileglancer file " f"share, so the client cannot address it.{RESET}\n{error}\n\n" - f"Re-run this from a directory under one of those mount " - f"points, or start the server with -f {cwd}") + f"Re-run this from a directory under one of the server's " + f"mounted shares, or start the server with -f {cwd}") print(f"{BOLD}Fileglancer Python client walkthrough{RESET}") print(f" server {url}") @@ -194,7 +194,7 @@ def main(): r.step("Ask about a path in no file share (expected to raise)", "fg._resolve('/definitely/not/a/share')", lambda: _expect_error(fg._resolve, "/definitely/not/a/share"), - verify="the error should list the available mount points") + verify="names the path; deliberately does not list every share") # --- Files --- r.step("Describe the current directory", diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py index f3dedf873..c32332a3b 100644 --- a/tests/test_client_paths.py +++ b/tests/test_client_paths.py @@ -112,17 +112,23 @@ def test_error_when_the_server_has_no_shares_says_so(fg): message = str(excinfo.value) assert "no file shares configured" in message assert "file_share_mounts" in message - assert "Available mount points" not in message -def test_unmatched_path_error_lists_available_mounts(fg): +def test_unmatched_path_error_names_the_path_but_not_every_share(fg): + """The message must not enumerate the shares. + + A real deployment has hundreds of them, and dumping the list buries the + one line the caller actually needs to read. + """ with pytest.raises(FileglancerError) as excinfo: fg._resolve("/not/a/share/at/all") message = str(excinfo.value) assert "/not/a/share/at/all" in message - assert "/nearline" in message - assert "/home/alice" in message + assert "file_share_paths()" in message + # The fixture's own mount paths must not appear. + assert "/nearline" not in message + assert "/home/alice" not in message def test_abspath_round_trips_resolve(fg): From beeec48eae366adc947ab1003d0fdaed43d4de9d Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 11:14:18 -0400 Subject: [PATCH 31/43] docs: amend the Python API spec with implementation divergences Appends an Amendments section rather than editing the approved design, so the document records what was decided on 2026-08-24 and, separately, where the shipped code deliberately went elsewhere. Chief among them: the no-match resolution error no longer enumerates the mount points. The spec called that message load-bearing, which was right -- but a real deployment mounts hundreds of shares, so listing them buried the line the caller needed. The spec's intent is what motivated the change. Also records the dropped cross-language resolver fixture, the two dead scope-table rows, the untouched TableCard DataType, the UTC offset on token timestamps, and jobs:write being full-account access. Co-Authored-By: Claude Opus 5 (1M context) --- .../specs/2026-08-24-python-api-design.md | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/superpowers/specs/2026-08-24-python-api-design.md b/docs/superpowers/specs/2026-08-24-python-api-design.md index ec52f5e86..ea021abc2 100644 --- a/docs/superpowers/specs/2026-08-24-python-api-design.md +++ b/docs/superpowers/specs/2026-08-24-python-api-design.md @@ -300,3 +300,33 @@ Frontend: - `frontend/src/components/ui/ApiTokens/` — new; list card and create dialog. - `frontend/src/queries/` — token queries. - `frontend/src/components/ui/Table/TableCard.tsx` — add the new row type to `DataType`. + +--- + +## Amendments + +The sections above are the design as approved on 2026-08-24 and are left unchanged. This section records where the shipped implementation deliberately diverges from them, so the document can be read without being misled. + +The header still reads "Status: approved, not yet implemented", which was true when written. As of 2026-08-25 the feature is implemented on branch `python-api-tokens` and reviewed; the header is left alone rather than edited, and this note stands in for it. + +### 2026-08-25 — the no-match error no longer lists the mount points + +Two places above specify that a path matching no file share raises an error naming the available mount points: "No match raises `FileglancerError` with a message listing the available mount points. This error message is load-bearing — it is the difference between a usable library and an infuriating one," and, under testing, "No-match error text names the available mount points." + +That reasoning was sound but the conclusion did not survive contact with a real deployment, which mounts hundreds of shares. Enumerating them buried the one line the caller needed to read. The message now names the offending path and points at `file_share_paths()` for discovery, which is a fixed length regardless of how many shares exist: + +``` +No file share matches '/definitely/not/a/share'. Call file_share_paths() to see the available shares. +``` + +The spec's underlying intent — that this error be the difference between a usable library and an infuriating one — is what motivated the change rather than what was abandoned. + +A separate message covers the case where the server has no shares configured at all, which the spec did not anticipate. There the empty list is a server configuration problem, so the error names `file_share_mounts` instead of discussing the caller's path. Both cases have regression tests in `tests/test_client_paths.py`. + +### 2026-08-25 — other divergences found during implementation + +- **The cross-language resolver fixture was dropped.** Testing above asks for "a shared fixture set asserting the Python and TypeScript resolvers agree." `resolvePathToFsp` already had its own suite covering the same behaviours, including the prefix-boundary case, so a shared fixture harness would have added machinery whose only job was detecting drift between two independently-tested implementations. Each suite covers longest-prefix and the boundary case separately; verified by reading both. +- **`/api/auth/status` and `/api/cluster-defaults` are not in the scope table.** The spec lists them, but neither route depends on `get_current_user`, so `required_scope` is never consulted for either and the rows could not execute. They were removed as dead configuration. +- **`TableCard`'s `DataType` union was left alone.** The Files-touched list above includes it, but the API Tokens page uses a card list rather than a table, so no new row type was needed. +- **Token timestamps carry an explicit UTC offset.** Not specified above, but required in practice: `Column(DateTime)` is naive, and a suffix-less ISO string is parsed as local time by JavaScript, which made the "Expired" badge wrong by the viewer's UTC offset. +- **`jobs:write` is documented as full-account access.** The scope model above implies containment it does not have: `JobSubmitRequest` accepts free-form `pre_run`/`post_run` shell that runs as the user, so a `jobs:write` token reaches every file regardless of the `files:*` scopes granted. The create dialog and the user docs now say so. From 47e5ac54a1b95fb299c890f8d41db2a59fd45530 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 11:33:03 -0400 Subject: [PATCH 32/43] feat: make the supported API token scope set server-configurable Adds api_token_scopes, defaulting to every scope except files:write and jobs:write. Both amount to full access to the user's files -- jobs:write runs arbitrary code as the user, so it reaches every file regardless of the files:* scopes granted -- so an admin now opts into them per server rather than inheriting them. Enforced in two places, which is what makes the setting a control rather than a formality: POST /api/tokens refuses a disabled scope, and token verification intersects each token's granted scopes with the enabled set, so removing a scope revokes it from tokens that already hold it instead of only restricting new ones. A files:write token degrades to files:read. The two 403s are worded differently on purpose. A missing scope is the user's to fix by minting a new token; a scope the server does not support is not, so that message names the administrator. An unknown scope name in the config fails at startup rather than silently narrowing what users can mint. The creation dialog offers only supported scopes, constrains its selection to them so a stale default cannot be submitted, and says to contact an admin when any are withheld. Cookie sessions are unaffected: the setting governs tokens only. Co-Authored-By: Claude Opus 5 (1M context) --- docs/config.yaml.template | 18 ++ fileglancer/auth.py | 13 ++ fileglancer/server.py | 23 +++ fileglancer/settings.py | 36 ++++ .../componentTests/ApiTokens.test.tsx | 74 +++++++- .../ui/ApiTokens/CreateTokenDialog.tsx | 32 +++- frontend/src/queries/apiTokenQueries.ts | 36 +++- tests/test_api_scopes.py | 36 ++++ tests/test_api_token_auth.py | 7 +- tests/test_disabled_scopes.py | 176 ++++++++++++++++++ 10 files changed, 443 insertions(+), 8 deletions(-) create mode 100644 tests/test_disabled_scopes.py diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 24c0102ac..5094211ee 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -139,6 +139,24 @@ session_cookie_secure: true # - https://ai-cryoet.int.janelia.org # - https://nextflow.int.janelia.org:8444 +# Which API token scopes this server supports. A scope left out cannot be +# granted to a new token, and is ignored on tokens that already hold it -- so +# removing one here revokes the capability from existing tokens rather than +# only affecting new ones. +# +# files:write and jobs:write are absent from the default shown below because +# both amount to full access to the user's files: jobs:write runs arbitrary +# code as the user, so it reaches every file regardless of the files:* scopes +# granted. Enable them per server, deliberately. +# +# api_token_scopes: +# - files:read +# - files:write +# - links:read +# - links:write +# - jobs:read +# - jobs:write + # # Maximum size in bytes accepted by a PUT /api/content upload. Guards the # streaming write path against an unbounded upload filling the disk. Set to 0 diff --git a/fileglancer/auth.py b/fileglancer/auth.py index be9a998b5..deaa436b5 100644 --- a/fileglancer/auth.py +++ b/fileglancer/auth.py @@ -346,6 +346,19 @@ def get_user_from_token(request: Request, settings: Settings, raw_token: str) -> raise HTTPException( status_code=403, detail=f"{request.url.path} is not accessible with an API token") + + # Scopes the server no longer supports are dropped from the token, so + # disabling one in api_token_scopes takes effect on tokens that already hold + # it rather than only on new ones. Reported separately from a plain missing + # scope: the user cannot fix a server-side decision by minting a new token. + enabled = set(settings.api_token_scopes) + if required and required not in enabled: + raise HTTPException( + status_code=403, + detail=f"The {required} scope is not enabled on this server. " + f"Contact your Fileglancer administrator if you need it.") + granted = " ".join(scope for scope in granted.split() if scope in enabled) + if not token_has_scope(granted, required): raise HTTPException( status_code=403, diff --git a/fileglancer/server.py b/fileglancer/server.py index a9aca1c0b..295c6db57 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -1480,6 +1480,17 @@ async def generate_temp_ssh_key( # API token management. These endpoints are session-only: they are absent # from the scope table in auth.py, so deny-by-default means a token can # never be used to mint or revoke another token. + @app.get("/api/tokens/scopes", response_model=dict, + description="List the API token scopes this server supports") + async def list_enabled_token_scopes(username: str = Depends(get_current_user)): + """Tell the UI which scope checkboxes to offer. + + Session-only like the other /api/tokens routes, since only the token + creation dialog needs it. + """ + return {"scopes": sorted(settings.api_token_scopes)} + + @app.get("/api/tokens", response_model=ApiTokenListResponse, description="List the current user's API tokens") async def list_api_tokens(username: str = Depends(get_current_user)): @@ -1504,6 +1515,18 @@ async def create_api_token(payload: ApiTokenCreateRequest, detail=f"Unknown scopes: {', '.join(unknown)}. " f"Valid scopes: {', '.join(sorted(auth.API_SCOPES))}") + # A real scope this server does not support. Distinguished from an + # unknown scope because the remedy is different: the user cannot fix + # this themselves. + disabled = sorted(set(payload.scopes) - set(settings.api_token_scopes)) + if disabled: + raise HTTPException( + status_code=400, + detail=f"Not enabled on this server: {', '.join(disabled)}. " + f"Contact your Fileglancer administrator if you need " + f"them. Enabled scopes: " + f"{', '.join(sorted(settings.api_token_scopes))}") + with db.get_db_session(settings.db_url) as session: row, secret = db.create_api_token( session, username, payload.name, payload.scopes, diff --git a/fileglancer/settings.py b/fileglancer/settings.py index 73a3dfcb2..b22b0e2ea 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -128,6 +128,22 @@ class Settings(BaseSettings): # the programmatic API. Empty (default) means only same-origin calls work. api_allowed_origins: List[str] = [] + # Which API token scopes this server supports. A scope left out of this list + # cannot be granted to a new token, and is ignored on tokens that already + # hold it -- so removing one here genuinely removes the capability rather + # than only affecting future tokens. + # + # files:write and jobs:write are deliberately absent by default. Both amount + # to full access to the user's files (jobs:write runs arbitrary code as the + # user), so an admin should opt into them per server rather than inherit + # them. + api_token_scopes: List[str] = [ + 'files:read', + 'links:read', + 'links:write', + 'jobs:read', + ] + # CLI mode - enables auto-login endpoint for standalone CLI usage cli_mode: bool = False @@ -172,6 +188,26 @@ def validate_external_proxy_url(cls, v): raise ValueError("Add external_proxy_url to your config.yaml or FGC_EXTERNAL_PROXY_URL to your .env file") return v + @field_validator('api_token_scopes') + @classmethod + def validate_api_token_scopes(cls, v: List[str]) -> List[str]: + """Reject unknown scope names at startup. + + A typo would otherwise silently narrow what users can mint, which is + the kind of misconfiguration nobody notices until someone cannot create + the token they need. + """ + # Imported here rather than at module scope: auth imports settings, so + # a top-level import would be circular. + from fileglancer.auth import API_SCOPES + + unknown = sorted(set(v) - API_SCOPES) + if unknown: + raise ValueError( + f"Unknown api_token_scopes: {', '.join(unknown)}. " + f"Valid scopes: {', '.join(sorted(API_SCOPES))}") + return v + @field_validator('max_directory_count') @classmethod def validate_max_directory_count(cls, v: int) -> int: diff --git a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx index a2f75a7e3..7a483e800 100644 --- a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx +++ b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx @@ -3,6 +3,7 @@ import { render, screen, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import ApiTokens from '@/components/ApiTokens'; +import { API_SCOPES as actualApiScopes } from '@/queries/apiTokenQueries'; import type { ApiTokenInfo, CreateTokenResult @@ -11,6 +12,7 @@ import type { const mockUseApiTokensQuery = vi.fn(); const mockUseCreateApiTokenMutation = vi.fn(); const mockUseDeleteApiTokenMutation = vi.fn(); +const mockUseEnabledScopesQuery = vi.fn(); vi.mock('@/queries/apiTokenQueries', async () => { const actual = await vi.importActual< @@ -22,7 +24,8 @@ vi.mock('@/queries/apiTokenQueries', async () => { SCOPE_WARNINGS: actual.SCOPE_WARNINGS, useApiTokensQuery: () => mockUseApiTokensQuery(), useCreateApiTokenMutation: () => mockUseCreateApiTokenMutation(), - useDeleteApiTokenMutation: () => mockUseDeleteApiTokenMutation() + useDeleteApiTokenMutation: () => mockUseDeleteApiTokenMutation(), + useEnabledScopesQuery: () => mockUseEnabledScopesQuery() }; }); @@ -51,8 +54,19 @@ function setTokens(tokens: ApiTokenInfo[]) { }); } +function setEnabledScopes(scopes: string[]) { + mockUseEnabledScopesQuery.mockReturnValue({ + data: scopes, + isLoading: false, + error: null + }); +} + beforeEach(() => { vi.clearAllMocks(); + // Most tests exercise the scope UI itself, so default to a permissive + // server; the withheld-scope tests narrow it explicitly. + setEnabledScopes([...actualApiScopes]); mockUseCreateApiTokenMutation.mockReturnValue({ mutateAsync: mockCreateMutateAsync, isPending: false, @@ -241,6 +255,64 @@ describe('ApiTokens page', () => { ).toBeInTheDocument(); }); + it('offers only the scopes the server supports', async () => { + setTokens([]); + setEnabledScopes(['files:read', 'links:read']); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + + expect(screen.getByLabelText('files:read')).toBeInTheDocument(); + expect(screen.getByLabelText('links:read')).toBeInTheDocument(); + // Withheld by the server, so there is no way to ask for it. + expect(screen.queryByLabelText('files:write')).toBeNull(); + expect(screen.queryByLabelText('jobs:write')).toBeNull(); + }); + + it('points at the administrator when scopes are withheld', async () => { + setTokens([]); + setEnabledScopes(['files:read']); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + + expect(screen.getByText(/not enabled on this server/i)).toBeInTheDocument(); + expect(screen.getByText(/administrator/i)).toBeInTheDocument(); + }); + + it('says nothing about withheld scopes when all six are enabled', async () => { + setTokens([]); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + + expect(screen.queryByText(/not enabled on this server/i)).toBeNull(); + }); + + it('never submits a scope the server withholds', async () => { + setTokens([]); + // files:read is selected by default, but the server has withdrawn it. + setEnabledScopes(['links:read']); + const user = userEvent.setup(); + + render(); + await user.click(screen.getByRole('button', { name: /new token/i })); + await user.type(screen.getByLabelText('Name'), 'reader'); + + // The stale default alone must not satisfy the form. + expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled(); + + await user.click(screen.getByLabelText('links:read')); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + expect(mockCreateMutateAsync.mock.calls[0][0].scopes).toEqual([ + 'links:read' + ]); + }); + it('disables Create when the name is whitespace-only', async () => { setTokens([]); const user = userEvent.setup(); diff --git a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx index 21cab4925..e8215d517 100644 --- a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -14,7 +14,8 @@ import { API_SCOPES, SCOPE_DESCRIPTIONS, SCOPE_WARNINGS, - useCreateApiTokenMutation + useCreateApiTokenMutation, + useEnabledScopesQuery } from '@/queries/apiTokenQueries'; import type { ApiScope, CreateTokenResult } from '@/queries/apiTokenQueries'; @@ -39,12 +40,26 @@ export default function CreateTokenDialog({ const [scopes, setScopes] = useState(['files:read']); const [expiryDays, setExpiryDays] = useState(30); const createToken = useCreateApiTokenMutation(); + const enabledScopes = useEnabledScopesQuery(); - const canSubmit = name.trim().length > 0 && scopes.length > 0; + // Only offer what the server supports. files:write and jobs:write are + // withheld unless an admin enables them, so the checkbox list is a subset + // of API_SCOPES on most servers. + const available = API_SCOPES.filter(scope => + (enabledScopes.data ?? []).includes(scope) + ); + const withheldCount = API_SCOPES.length - available.length; + + // Constrain the selection to what is actually on offer, so a default or a + // stale selection can never be submitted for a scope the server would + // reject. + const effectiveScopes = scopes.filter(scope => available.includes(scope)); + + const canSubmit = name.trim().length > 0 && effectiveScopes.length > 0; // Warnings for whichever dangerous scopes are currently selected, iterated in // API_SCOPES order so the list stays stable as boxes are ticked. - const selectedWarnings = API_SCOPES.flatMap(scope => { + const selectedWarnings = available.flatMap(scope => { const warning = SCOPE_WARNINGS[scope]; return warning && scopes.includes(scope) ? [{ scope, warning }] : []; }); @@ -94,7 +109,7 @@ export default function CreateTokenDialog({ try { const result = await createToken.mutateAsync({ name: name.trim(), - scopes, + scopes: effectiveScopes, expires_in_days: expiryDays }); handleClose(); @@ -127,7 +142,7 @@ export default function CreateTokenDialog({ wide as its widest cell across all rows, which is what lines the descriptions up without hard-coding a width. */}
- {API_SCOPES.map(scope => { + {available.map(scope => { const locked = isImpliedByWrite(scope); const descriptionId = `scope-${scope}-description`; return ( @@ -150,6 +165,13 @@ export default function CreateTokenDialog({ })}
+ {withheldCount > 0 ? ( + + Some scopes are not enabled on this server. Contact your Fileglancer + administrator if you need one that is not listed. + + ) : null} + {selectedWarnings.length > 0 ? (
['apiTokens', 'list'] as const + list: () => ['apiTokens', 'list'] as const, + scopes: () => ['apiTokens', 'scopes'] as const }; +/** + * The scopes this server supports, which may be a subset of API_SCOPES. + * + * `files:write` and `jobs:write` are withheld by default because both amount + * to full access to the user's files; an admin opts into them per server. + */ +const fetchEnabledScopes = async (signal?: AbortSignal): Promise => { + const response = await sendFetchRequest( + '/api/tokens/scopes', + 'GET', + undefined, + { signal } + ); + + const body = await getResponseJsonOrError(response); + + if (!response.ok) { + throwResponseNotOkError(response, body); + } + + return (body as { scopes: string[] }).scopes ?? []; +}; + +/** + * Query hook for the scopes this server supports. + */ +export function useEnabledScopesQuery(): UseQueryResult { + return useQuery({ + queryKey: apiTokenQueryKeys.scopes(), + queryFn: ({ signal }) => fetchEnabledScopes(signal) + }); +} + /** * Fetches all API tokens for the current user from the backend */ diff --git a/tests/test_api_scopes.py b/tests/test_api_scopes.py index 71ca92da9..78ef27fc3 100644 --- a/tests/test_api_scopes.py +++ b/tests/test_api_scopes.py @@ -83,3 +83,39 @@ def test_token_has_scope(granted, required, expected): @pytest.mark.parametrize("granted", ["", "files:read", "jobs:write"]) def test_any_scope_is_satisfied_by_any_token(granted): assert token_has_scope(granted, ANY_SCOPE) is True + + +# --- Server-configurable scope set ----------------------------------------- + +def test_dangerous_scopes_are_absent_from_the_default_config(): + """files:write and jobs:write must be opt-in per server. + + Both amount to full access to the user's files, so inheriting them by + default would defeat the point of having a scope model at all. + """ + from fileglancer.settings import Settings + + default = Settings(external_proxy_url="http://localhost/files").api_token_scopes + + assert "files:write" not in default + assert "jobs:write" not in default + assert set(default) == {"files:read", "links:read", "links:write", "jobs:read"} + + +def test_unknown_scope_in_config_is_rejected_at_startup(): + from pydantic import ValidationError + from fileglancer.settings import Settings + + with pytest.raises(ValidationError, match="Unknown api_token_scopes"): + Settings(external_proxy_url="http://localhost/files", + api_token_scopes=["files:read", "files:delete"]) + + +def test_every_scope_is_configurable(): + """Any subset of the six must be a valid configuration.""" + from fileglancer.settings import Settings + + settings = Settings(external_proxy_url="http://localhost/files", + api_token_scopes=sorted(API_SCOPES)) + + assert set(settings.api_token_scopes) == API_SCOPES diff --git a/tests/test_api_token_auth.py b/tests/test_api_token_auth.py index 62d7c140d..620337191 100644 --- a/tests/test_api_token_auth.py +++ b/tests/test_api_token_auth.py @@ -22,6 +22,7 @@ dispose_engine, sessionmaker, ) +from fileglancer.auth import API_SCOPES from fileglancer.server import create_app from fileglancer.settings import Settings @@ -41,7 +42,11 @@ def token_app(): )) db_session.commit() - settings = Settings(db_url=db_url, file_share_mounts=[], cli_mode=True) + # Enables every scope: these tests exercise the scope model itself, not the + # server's shipped default (which withholds files:write and jobs:write). + # test_disabled_scopes.py covers the restricted configuration. + settings = Settings(db_url=db_url, file_share_mounts=[], cli_mode=True, + api_token_scopes=sorted(API_SCOPES)) # get_current_user resolves settings via get_settings() at request time. # server.py binds that name at import, so patch it there too (not just in # the settings/database modules) — otherwise the real endpoint would look diff --git a/tests/test_disabled_scopes.py b/tests/test_disabled_scopes.py new file mode 100644 index 000000000..50fa4fa2e --- /dev/null +++ b/tests/test_disabled_scopes.py @@ -0,0 +1,176 @@ +"""Tests for the server-configurable API token scope set. + +`api_token_scopes` withholds files:write and jobs:write by default. A scope +left out of it cannot be granted to a new token AND is ignored on tokens that +already hold it, so an admin who removes one genuinely removes the capability +instead of only affecting tokens minted afterwards. + +The shared `token_app` fixture in test_api_token_auth enables all six scopes; +this module builds its own app with a restricted set. +""" +import os +import shutil +import tempfile + +import pytest +from fastapi.testclient import TestClient + +import fileglancer.database +import fileglancer.server +import fileglancer.settings +from fileglancer.database import ( + Base, + FileSharePathDB, + create_api_token, + create_engine, + dispose_engine, + sessionmaker, +) +from fileglancer.server import create_app +from fileglancer.settings import Settings + + +@pytest.fixture +def restricted_app(): + """App whose config withholds files:write, as the default does.""" + temp_dir = tempfile.mkdtemp() + db_url = f"sqlite:///{os.path.join(temp_dir, 'test.db')}" + engine = create_engine(db_url) + Base.metadata.create_all(engine) + db_session = sessionmaker(bind=engine)() + db_session.add(FileSharePathDB( + name="tempdir", zone="testzone", group="testgroup", storage="local", + mount_path=temp_dir, + )) + db_session.commit() + + settings = Settings(db_url=db_url, file_share_mounts=[], cli_mode=True, + api_token_scopes=["files:read", "links:read"]) + originals = (fileglancer.settings.get_settings, + fileglancer.database.get_settings, + fileglancer.server.get_settings) + fileglancer.settings.get_settings = lambda: settings + fileglancer.database.get_settings = lambda: settings + fileglancer.server.get_settings = lambda: settings + + yield create_app(settings), db_session + + db_session.close() + engine.dispose() + dispose_engine(db_url) + from fileglancer.user_worker import _filestore_cache, _user_groups_cache + _filestore_cache.clear() + _user_groups_cache.clear() + (fileglancer.settings.get_settings, + fileglancer.database.get_settings, + fileglancer.server.get_settings) = originals + shutil.rmtree(temp_dir) + + +def _client(app, plaintext): + client = TestClient(app) + client.headers["Authorization"] = f"Bearer {plaintext}" + return client + + +def test_enabled_scopes_endpoint_reports_the_configured_set(restricted_app): + app, _ = restricted_app + # Session-only, so use the dependency override rather than a token. + from fileglancer.server import get_current_user + app.dependency_overrides[get_current_user] = lambda: "alice" + + response = TestClient(app).get("/api/tokens/scopes") + + assert response.status_code == 200 + assert response.json() == {"scopes": ["files:read", "links:read"]} + app.dependency_overrides.clear() + + +def test_creating_a_token_with_a_disabled_scope_is_refused(restricted_app): + app, _ = restricted_app + from fileglancer.server import get_current_user + app.dependency_overrides[get_current_user] = lambda: "alice" + + response = TestClient(app).post("/api/tokens", json={ + "name": "writer", "scopes": ["files:read", "files:write"], + }) + + assert response.status_code == 400 + detail = response.json()["error"] + assert "files:write" in detail + assert "administrator" in detail + # The remedy differs from an unknown scope, so the wording must too. + assert "Unknown scopes" not in detail + app.dependency_overrides.clear() + + +def test_an_enabled_scope_is_still_grantable(restricted_app): + app, _ = restricted_app + from fileglancer.server import get_current_user + app.dependency_overrides[get_current_user] = lambda: "alice" + + response = TestClient(app).post("/api/tokens", json={ + "name": "reader", "scopes": ["files:read"], + }) + + assert response.status_code == 201 + app.dependency_overrides.clear() + + +def test_a_token_holding_a_since_disabled_scope_loses_it(restricted_app): + """The case the design turns on. + + The token is created directly in the database, standing in for one minted + while the scope was still enabled. Disabling it must take effect on that + existing token, not just on tokens created afterwards. + """ + app, db_session = restricted_app + _, plaintext = create_api_token(db_session, "alice", "legacy", + ["files:read", "files:write"]) + client = _client(app, plaintext) + + # The read half still works. + assert client.get("/api/files/tempdir").status_code == 200 + + # The write half does not, even though the token carries it. + response = client.post("/api/files/tempdir?subpath=newdir", + json={"type": "directory"}) + + assert response.status_code == 403 + detail = response.json()["error"] + assert "files:write" in detail + assert "not enabled on this server" in detail + assert "administrator" in detail + + +def test_the_disabled_message_differs_from_a_missing_scope(restricted_app): + """A user can fix a missing scope by minting a new token; they cannot fix + a server-side decision, so the two must not read the same.""" + app, db_session = restricted_app + _, plaintext = create_api_token(db_session, "alice", "reader", + ["files:read"]) + client = _client(app, plaintext) + + # links:write is disabled server-wide. + disabled = client.post("/api/proxied-path?fsp_name=tempdir&path=x") + assert disabled.status_code == 403 + assert "not enabled on this server" in disabled.json()["error"] + + # links:read IS enabled; this token simply was not granted it. + missing = client.get("/api/proxied-path") + assert missing.status_code == 403 + assert "missing the required scope" in missing.json()["error"] + + +def test_disabling_a_scope_does_not_affect_cookie_sessions(restricted_app): + """The scope set governs tokens only; a logged-in browser is unaffected.""" + app, _ = restricted_app + from fileglancer.server import get_current_user + app.dependency_overrides[get_current_user] = lambda: "alice" + + # files:write is disabled, but this is a session, not a token. + response = TestClient(app).post("/api/files/tempdir?subpath=viacookie", + json={"type": "directory"}) + + assert response.status_code == 201 + app.dependency_overrides.clear() From 8b01ca6f28b8f95fbc63e9c7d61d590939ed5efd Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 11:48:04 -0400 Subject: [PATCH 33/43] fix: surface why a token revoke failed A failed DELETE left the confirmation dialog open with no explanation. Nothing implied success -- the dialog stays open and the token remains listed, since invalidateQueries only runs on success -- but isPending cleared and the button re-enabled, so clicking again failed identically with no clue why: a 404 because someone else revoked it, a 403 from an expired session, a dropped connection. The dialog now renders the error, matching what CreateTokenDialog already does. Closing it resets the mutation, or a failure on one token would greet the user when they opened the dialog for a different one -- the same stale error bug already fixed on the create side. Closing is ignored while a revoke is in flight, so the outcome is always shown. Co-Authored-By: Claude Opus 5 (1M context) --- .../componentTests/ApiTokens.test.tsx | 70 +++++++++++++++++++ frontend/src/components/ApiTokens.tsx | 15 +++- .../ui/ApiTokens/RevokeTokenDialog.tsx | 9 +++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx index 7a483e800..1c6f978a8 100644 --- a/frontend/src/__tests__/componentTests/ApiTokens.test.tsx +++ b/frontend/src/__tests__/componentTests/ApiTokens.test.tsx @@ -313,6 +313,76 @@ describe('ApiTokens page', () => { ]); }); + it('shows why a revoke failed instead of failing silently', async () => { + setTokens([existingToken]); + const failure = new Error('Token not found'); + mockUseDeleteApiTokenMutation.mockReturnValue({ + mutate: mockDeleteMutate, + isPending: false, + error: failure, + reset: vi.fn() + }); + const user = userEvent.setup(); + + render(); + const card = screen.getByTestId('api-token-list'); + await user.click(within(card).getByRole('button', { name: 'Revoke' })); + + expect( + screen.getByText(/could not revoke this token/i) + ).toBeInTheDocument(); + expect(screen.getByText(/Token not found/)).toBeInTheDocument(); + // The dialog stays open and the token is still listed, so nothing implies + // the revoke worked. + expect( + screen.getByRole('button', { name: 'Revoke Token' }) + ).toBeInTheDocument(); + expect(within(card).getByText('laptop notebook')).toBeInTheDocument(); + }); + + it('clears a previous revoke failure when the dialog is closed', async () => { + setTokens([existingToken]); + const reset = vi.fn(); + mockUseDeleteApiTokenMutation.mockReturnValue({ + mutate: mockDeleteMutate, + isPending: false, + error: new Error('Token not found'), + reset + }); + const user = userEvent.setup(); + + render(); + const card = screen.getByTestId('api-token-list'); + await user.click(within(card).getByRole('button', { name: 'Revoke' })); + await user.click(screen.getByRole('button', { name: 'Keep token' })); + + // Without this the stale error would greet the user on the next token. + expect(reset).toHaveBeenCalled(); + }); + + it('ignores a close while a revoke is in flight', async () => { + setTokens([existingToken]); + const reset = vi.fn(); + mockUseDeleteApiTokenMutation.mockReturnValue({ + mutate: mockDeleteMutate, + isPending: true, + error: null, + reset + }); + const user = userEvent.setup(); + + render(); + const card = screen.getByTestId('api-token-list'); + await user.click(within(card).getByRole('button', { name: 'Revoke' })); + // Buttons are disabled while pending, so drive the dismiss path directly. + await user.keyboard('{Escape}'); + + expect(reset).not.toHaveBeenCalled(); + // Still open. Matched by heading rather than by the confirm button, whose + // accessible name is "Revoking..." while the request is in flight. + expect(screen.getByText('Revoke API Token')).toBeInTheDocument(); + }); + it('disables Create when the name is whitespace-only', async () => { setTokens([]); const user = userEvent.setup(); diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx index 7d5ab37fa..ed6dcfc67 100644 --- a/frontend/src/components/ApiTokens.tsx +++ b/frontend/src/components/ApiTokens.tsx @@ -34,6 +34,18 @@ export default function ApiTokens() { }); }; + const handleCloseRevoke = () => { + if (deleteToken.isPending) { + // A revoke is already on the wire; closing here would leave the user + // with no indication of how it turned out. + return; + } + // Reset the mutation, or a failure from this token would greet the user + // when they open the dialog for a different one. + deleteToken.reset(); + setPendingRevoke(null); + }; + const tokens = data ?? []; const hasTokens = tokens.length > 0; @@ -127,8 +139,9 @@ export default function ApiTokens() { setNewToken(null)} result={newToken} /> setPendingRevoke(null)} + onClose={handleCloseRevoke} onConfirm={handleConfirmRevoke} open={pendingRevoke !== null} tokenName={pendingRevoke?.name ?? ''} diff --git a/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx index f83a5aa26..742f011c6 100644 --- a/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx +++ b/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx @@ -8,6 +8,8 @@ type RevokeTokenDialogProps = { readonly open: boolean; readonly tokenName: string; readonly isPending: boolean; + /** Message from a failed revoke, so a retry is not blind. */ + readonly error: string | null; readonly onClose: () => void; readonly onConfirm: () => void; }; @@ -16,6 +18,7 @@ export default function RevokeTokenDialog({ open, tokenName, isPending, + error, onClose, onConfirm }: RevokeTokenDialogProps) { @@ -29,6 +32,12 @@ export default function RevokeTokenDialog({ or notebook using this token will stop working immediately. This cannot be undone. + {error ? ( + + Could not revoke this token: {error} + + ) : null} +
Keep token From 4dc6efa85bdf59b7050def140bf83a6db90acabf Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 13:13:19 -0400 Subject: [PATCH 34/43] fix: normalize separators in abspath, and compare paths portably in tests Windows CI caught two things. abspath joined a '/'-separated relative path onto mount_path verbatim, so a Windows mount path came back with mixed separators: 'C:\shares\data/sub/file.txt'. _resolve already normalizes backslashes to '/' on the way in; abspath now does the same on the way out, so the round trip is self-consistent and the documented "always in Linux form" claim holds. No effect on POSIX, where mount_path has no backslashes. The remaining failures were the tests, not the code: they compared client output against os.path.join output literally. Windows spells one directory several ways -- separator style, 8.3 short names (RUNNER~1 vs runneradmin), and case -- so a shared conftest.same_path helper normalizes both sides with normcase plus realpath before comparing. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/client.py | 10 ++++++++-- tests/conftest.py | 21 ++++++++++++++++++++- tests/test_client_files.py | 3 ++- tests/test_client_links.py | 8 +++++--- tests/test_client_paths.py | 20 ++++++++++++++++++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index 9d71aa3b3..d5e520919 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -151,10 +151,16 @@ def _resolve(self, path: str) -> Tuple[str, str]: return best_fsp.name, normalized[len(best_prefix):].strip("/") def abspath(self, fsp_name: str, path: str = "") -> str: - """Build an absolute path from a file share name and relative path.""" + """Build an absolute path from a file share name and relative path. + + Separators are normalized to '/', matching what `_resolve` accepts and + returns. Without this a Windows mount path would be joined to a + '/'-separated relative path and come back mixed, e.g. + 'C:\\shares\\data/sub/file.txt'. + """ for fsp in self.file_share_paths(): if fsp.name == fsp_name: - root = fsp.mount_path.rstrip("/") + root = fsp.mount_path.replace("\\", "/").rstrip("/") return f"{root}/{path}" if path else root raise FileglancerError(f"Unknown file share: {fsp_name}") diff --git a/tests/conftest.py b/tests/conftest.py index ce5cc540b..8ceb2409e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -50,4 +50,23 @@ def pytest_sessionstart(session): and entering the run test loop. """ os.environ['FGC_EXTERNAL_PROXY_URL'] = 'http://localhost/files' - os.environ['FGC_USE_ACCESS_FLAGS'] = 'false' \ No newline at end of file + os.environ['FGC_USE_ACCESS_FLAGS'] = 'false' + +def same_path(a: str, b: str) -> bool: + """Compare two paths that should point at the same place. + + Windows reaches one directory by several spellings: backslash or forward + slash separators, 8.3 short names (``RUNNER~1``) alongside long ones + (``runneradmin``), and case-insensitively. The Fileglancer client + deliberately normalizes separators to ``/``, so a literal comparison + against ``os.path.join`` output fails on Windows with neither side being + wrong. + + normcase lowercases and switches to the native separator on Windows and is + a no-op on POSIX; realpath collapses 8.3 names and symlinks. Applied to + both sides, so the comparison stays symmetric. + """ + def norm(path: str) -> str: + return os.path.normcase(os.path.realpath(path)).replace("\\", "/") + + return norm(a) == norm(b) diff --git a/tests/test_client_files.py b/tests/test_client_files.py index feed3e9a3..0accbf1b3 100644 --- a/tests/test_client_files.py +++ b/tests/test_client_files.py @@ -8,6 +8,7 @@ import pytest from fastapi.testclient import TestClient +from conftest import same_path from fileglancer.client import Fileglancer, FileglancerError from fileglancer.database import create_api_token @@ -54,7 +55,7 @@ def test_ls_returns_absolute_paths(fg, share_root): entry = next(f for f in fg.ls(share_root) if f.name == "adir") - assert entry.absolute_path == os.path.join(share_root, "adir") + assert same_path(entry.absolute_path, os.path.join(share_root, "adir")) def test_stat_describes_the_path_itself(fg, share_root): diff --git a/tests/test_client_links.py b/tests/test_client_links.py index aab9d3d9e..e60d19a92 100644 --- a/tests/test_client_links.py +++ b/tests/test_client_links.py @@ -4,6 +4,7 @@ import pytest from fastapi.testclient import TestClient +from conftest import same_path from fileglancer.client import NEUROGLANCER_URL, Fileglancer, FileglancerError from fileglancer.database import ( ProxiedPathDB, @@ -47,7 +48,7 @@ def test_create_data_link_from_an_absolute_path(fg, shared_dir): def test_created_data_link_reports_an_absolute_path(fg, shared_dir): link = fg.create_data_link(shared_dir) - assert link.path == shared_dir + assert same_path(link.path, shared_dir) def test_create_data_link_accepts_a_url_prefix(fg, shared_dir): @@ -66,7 +67,8 @@ def test_list_data_links_reports_absolute_paths(fg, shared_dir): links = fg.data_links() - assert [link.path for link in links] == [shared_dir] + assert len(links) == 1 + assert same_path(links[0].path, shared_dir) def test_data_links_lists_the_rest_when_one_link_has_a_stale_share(fg, shared_dir, token_app): @@ -98,7 +100,7 @@ def test_get_data_link_by_sharing_key(fg, shared_dir): fetched = fg.data_link(created.sharing_key) assert fetched.sharing_key == created.sharing_key - assert fetched.path == shared_dir + assert same_path(fetched.path, shared_dir) def test_delete_data_link(fg, shared_dir): diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py index c32332a3b..c85cbf21d 100644 --- a/tests/test_client_paths.py +++ b/tests/test_client_paths.py @@ -141,6 +141,26 @@ def test_abspath_of_the_share_root(fg): assert fg.abspath("nearline") == "/nearline" +def test_abspath_normalizes_windows_separators(fg): + """abspath must not emit mixed separators. + + _resolve normalizes backslashes to '/' on the way in, so abspath has to do + the same on the way out or a Windows mount path joined to a '/'-separated + relative path comes back as 'C:/shares/data\\sub/file.txt'. Caught by + Windows CI, where mount_path really does contain backslashes. + """ + fg._fsp_cache = [ + FileSharePath(name="winshare", zone="z", mount_path="C:\\shares\\data") + ] + + result = fg.abspath("winshare", "sub/file.txt") + + assert result == "C:/shares/data/sub/file.txt" + assert "\\" not in result + # And the round trip still holds. + assert fg._resolve(result) == ("winshare", "sub/file.txt") + + def test_abspath_rejects_an_unknown_share(fg): with pytest.raises(FileglancerError, match="Unknown file share"): fg.abspath("nosuchshare", "x") From 26c2b3f3aebbfffd5ea01b5ab2251fb01975c6c5 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 13:52:04 -0400 Subject: [PATCH 35/43] feat: log the username and token id for token-authenticated requests AccessLogMiddleware resolved identity from the session cookie only, so every programmatic request logged '-'. An admin investigating a misbehaving script had nothing to trace it to. get_user_from_token now leaves the resolved username and token id on request.state, which the middleware reads after awaiting call_next -- request.state is backed by scope["state"], so a value set in a dependency is visible upstream. Lines become: 127.0.0.1:37662 [rokickik fgt:4e626080362d] "GET /api/files/... " 200 The token id is the public half, not the secret, and is what the GUI shows, so it points at the exact token to revoke. The pre-existing cookie lookup is deliberately left in place rather than replaced by the same mechanism, even though it duplicates a query the dependency makes moments later: logout deletes the session during the request, so resolving identity only after call_next would log '-' for the request that did the logging out. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/auth.py | 9 +++++++ fileglancer/log.py | 14 +++++++++++ tests/test_api_token_auth.py | 26 ++++++++++++++++++++ tests/test_log.py | 47 +++++++++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/fileglancer/auth.py b/fileglancer/auth.py index deaa436b5..f9f0056f6 100644 --- a/fileglancer/auth.py +++ b/fileglancer/auth.py @@ -367,4 +367,13 @@ def get_user_from_token(request: Request, settings: Settings, raw_token: str) -> with db.get_db_session(settings.db_url) as session: db.touch_api_token(session, token_id) + # Left for AccessLogMiddleware, which otherwise logs token requests as '-' + # because it resolves identity from the session cookie only. request.state + # is backed by scope["state"], so a value set here is visible to the + # middleware after it awaits call_next. The token id is the public half and + # is what the GUI shows, so an admin tracing activity can point a user at + # the exact token to revoke. + request.state.fg_username = username + request.state.fg_token_id = token_id + return username diff --git a/fileglancer/log.py b/fileglancer/log.py index ad8011428..4c2bf1600 100644 --- a/fileglancer/log.py +++ b/fileglancer/log.py @@ -80,6 +80,19 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: # Process the request response = await call_next(request) + # Token-authenticated requests have no session cookie, so the lookup + # above found nothing and username is still '-'. get_user_from_token + # leaves the resolved identity on request.state, which is readable now + # that the endpoint has run. The cookie lookup above is deliberately + # left in place rather than replaced by this: logout deletes the + # session during the request, so resolving it only after call_next + # would log '-' for the request that did the logging out. + token_username = getattr(request.state, "fg_username", None) + if token_username: + token_id = getattr(request.state, "fg_token_id", None) + username = (f"{token_username} fgt:{token_id}" if token_id + else token_username) + # Calculate request duration duration_ms = (time.time() - start_time) * 1000 @@ -100,6 +113,7 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: # Format log message in a standard access log format # Example: 192.168.1.100:54321 [username] "GET /api/files HTTP/1.1" 200 - 45.23ms + # API token requests carry the token id: [username fgt:a1b2c3d4e5f6] log_message = ( f"{client_host}:{client_port} [{username}] " f'"{request.method} {_log_safe(path)}' diff --git a/tests/test_api_token_auth.py b/tests/test_api_token_auth.py index 620337191..ecae8696a 100644 --- a/tests/test_api_token_auth.py +++ b/tests/test_api_token_auth.py @@ -309,3 +309,29 @@ def test_every_authenticated_route_is_classified(token_app): "them to _SCOPE_PREFIXES in auth.py or to SESSION_ONLY_PREFIXES here: " f"{sorted(unclassified)}" ) + + +def test_access_log_names_the_user_and_token(token_app): + """End to end through the real bearer path, not a simulated request.state. + + Complements test_log.py, which stubs the state directly: this confirms + get_user_from_token actually populates it on a genuine token request. + """ + from loguru import logger + + app, db_session = token_app + row, plaintext = create_api_token(db_session, "alice", "tracing", + ["files:read"]) + + lines = [] + sink_id = logger.add(lambda msg: lines.append(msg), format="{message}") + try: + client = TestClient(app) + client.get("/api/files/tempdir", + headers={"Authorization": f"Bearer {plaintext}"}) + finally: + logger.remove(sink_id) + + access_lines = [line for line in lines if "HTTP/1.1" in line] + assert access_lines, lines + assert f"[alice fgt:{row.token_id}]" in access_lines[-1], access_lines[-1] diff --git a/tests/test_log.py b/tests/test_log.py index d6a46e632..22cc402df 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -2,7 +2,7 @@ import asyncio -from fastapi import FastAPI +from fastapi import FastAPI, Request from fastapi.testclient import TestClient from loguru import logger @@ -86,3 +86,48 @@ async def send(message): assert "\x1b" not in logged, repr(logged) # The encoded form is what gets logged, so both stay inert. assert "%0A" in logged and "%1B" in logged, repr(logged) + + +def test_token_requests_log_the_username_and_token_id(): + """A token request must be traceable to a user and a specific token. + + Before this, the middleware resolved identity from the session cookie only, + so every programmatic request logged '-' -- an admin investigating a + misbehaving script had nothing to go on. The token id is the public half + and is what the GUI shows, so it points at the exact token to revoke. + """ + app = FastAPI() + app.add_middleware(AccessLogMiddleware, settings=Settings()) + + @app.get("/api/files/{name}") + async def files(name: str, request: Request): + # Stands in for auth.get_user_from_token, which sets these once a + # bearer token resolves. + request.state.fg_username = "alice" + request.state.fg_token_id = "a1b2c3d4e5f6" + return {"ok": True} + + lines = [] + sink_id = logger.add(lambda msg: lines.append(msg), format="{message}") + try: + with TestClient(app) as client: + client.get("/api/files/share") + finally: + logger.remove(sink_id) + + assert len(lines) == 1, lines + assert "[alice fgt:a1b2c3d4e5f6]" in lines[0], lines[0] + assert "[-]" not in lines[0], lines[0] + + +def test_unauthenticated_requests_still_log_a_dash(): + """No identity to attribute, so the field stays '-'.""" + app, lines, sink_id = _app_and_lines() + try: + with TestClient(app) as client: + client.get("/api/content/share/file.txt") + finally: + logger.remove(sink_id) + + assert len(lines) == 1, lines + assert "[-]" in lines[0], lines[0] From cb9a9e7231702c862d11e497cb698c0c42ad9207 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 15:14:04 -0400 Subject: [PATCH 36/43] added python package link --- frontend/src/components/ApiTokens.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx index ed6dcfc67..1ff705c22 100644 --- a/frontend/src/components/ApiTokens.tsx +++ b/frontend/src/components/ApiTokens.tsx @@ -8,6 +8,7 @@ import ApiTokenCard from '@/components/ui/ApiTokens/ApiTokenCard'; import CreateTokenDialog from '@/components/ui/ApiTokens/CreateTokenDialog'; import NewTokenDialog from '@/components/ui/ApiTokens/NewTokenDialog'; import RevokeTokenDialog from '@/components/ui/ApiTokens/RevokeTokenDialog'; +import FgExternalLink from '@/components/designSystem/atoms/FgExternalLink'; import { Spinner } from '@/components/ui/widgets/Loaders'; import { useApiTokensQuery, @@ -57,9 +58,11 @@ export default function ApiTokens() { API tokens let scripts and notebooks use Fileglancer through the{' '} - fileglancer Python package. A token acts on your behalf, - limited to the scopes you grant it. The token is shown only once, when - you create it. + + authoring guide + + Python package. A token acts on your behalf, limited to the scopes you + grant it. The token is shown only once, when you create it. {isLoading ? ( From 669f6ccdd1f31600d6f6ce1be30ec4ef640d531a Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Tue, 25 Aug 2026 15:18:37 -0400 Subject: [PATCH 37/43] fix: stop rendering API token page text in the brand accent purple secondary is the purple brand accent (#6D28D9, commented "Purple color" in tailwind.config.js), not a muted grey. Using text-secondary for scope descriptions, card metadata and the empty state turned most of the page purple -- including the scope description column, which was supposed to be "slightly greyed". Switched to text-foreground/60, the muted-text idiom already used across the app, and dropped color="secondary" from the empty-state icon in favour of an explicit text-foreground/40 so it reads as decorative rather than inheriting by accident. The Apps pages use plain text-foreground throughout and never reach for secondary; this brings the tokens page in line. Page palette is now text-foreground for content, /60 muted, /40 decorative, and error/warning only where they carry meaning. Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/components/ApiTokens.tsx | 5 ++--- frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx | 4 ++-- frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx index 1ff705c22..206af4826 100644 --- a/frontend/src/components/ApiTokens.tsx +++ b/frontend/src/components/ApiTokens.tsx @@ -85,14 +85,13 @@ export default function ApiTokens() { {!isLoading && !error && !hasTokens ? ( No API tokens - + Create a token to use Fileglancer from Python. ) : null} - + {token.scopes.join(', ')} - + Created {formatDateOnly(token.created_at)} · Expires{' '} {formatDateOnly(token.expires_at)} · Last used{' '} {token.last_used_at ? formatDateOnly(token.last_used_at) : 'Never'} diff --git a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx index e8215d517..ad768843c 100644 --- a/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -155,7 +155,7 @@ export default function CreateTokenDialog({ onChange={() => toggleScope(scope)} /> {SCOPE_DESCRIPTIONS[scope]} @@ -166,7 +166,7 @@ export default function CreateTokenDialog({
{withheldCount > 0 ? ( - + Some scopes are not enabled on this server. Contact your Fileglancer administrator if you need one that is not listed. From 2d3d8b771989fe30ce626a85bcecdc47007f6488 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Wed, 26 Aug 2026 16:32:30 -0400 Subject: [PATCH 38/43] docs: name env vars plainly instead of using $VAR shell syntax The $FILEGLANCER_URL form in the client docstrings read like string interpolation to some people. Say "the FILEGLANCER_URL environment variable" instead. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/fileglancer/client.py b/fileglancer/client.py index d5e520919..4d5e6e110 100644 --- a/fileglancer/client.py +++ b/fileglancer/client.py @@ -6,7 +6,7 @@ from fileglancer import Fileglancer - fg = Fileglancer() # reads FILEGLANCER_URL and FILEGLANCER_TOKEN + fg = Fileglancer() # reads the FILEGLANCER_URL and FILEGLANCER_TOKEN env vars link = fg.create_data_link("/data/alice/sample.zarr") """ import os @@ -35,9 +35,10 @@ class Fileglancer: """A client for the Fileglancer HTTP API. Args: - url: Fileglancer server URL. Defaults to $FILEGLANCER_URL. - token: An API token created in the web UI. Defaults to - $FILEGLANCER_TOKEN. + url: Fileglancer server URL. Defaults to the FILEGLANCER_URL + environment variable. + token: An API token created in the web UI. Defaults to the + FILEGLANCER_TOKEN environment variable. timeout: Per-request timeout in seconds. """ From 082ddb438856829183d5f8d83947de823659cef0 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Thu, 27 Aug 2026 22:33:13 -0400 Subject: [PATCH 39/43] feat: serve data links as an S3 bucket so they browse in Neuroglancer Neuroglancer shows a folder tree over plain https by reading the first path segment of a URL as an S3 bucket and asking that bucket root for everything under the rest as a prefix: GET /files/?list-type=2&prefix={sharing_key}/{link name}/&delimiter=/ Nothing answered at /files/, so the probe 404'd and browsing was off for every data link. Reads were unaffected, which is why this only showed up as "the folder tree doesn't work". The whole /files space is now one bucket. A key is {sharing_key}/{link name}/{path}, which is exactly what a share URL already looks like, so existing links gain browsing without changing. Since the first two segments have no counterpart on disk, they are handed to x2s3 1.5.1's new virtual_prefix option, which strips them from requests and puts them back on the keys it reports. Sharing keys are secret, so the bucket root never enumerates them: a missing, partial or unknown key all produce the same empty listing, and the only key that can appear in a response is one the caller already supplied in full. Empty rather than an error is deliberate -- Neuroglancer remembers per-server whether S3 listing works at all, and an error there would turn off browsing for the valid links too. The existing per-key list branch is fixed the same way. It reported keys without the link name, so they didn't resolve as URLs, and it 404'd at the bucket root because a bucket has no path of its own to match. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/server.py | 198 ++++++++++++++++++++++++++++--------- fileglancer/user_worker.py | 2 + pixi.lock | 92 ++++++++--------- pyproject.toml | 4 +- tests/test_endpoints.py | 120 ++++++++++++++++++++++ 5 files changed, 324 insertions(+), 92 deletions(-) diff --git a/fileglancer/server.py b/fileglancer/server.py index 295c6db57..312a37b43 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -48,7 +48,7 @@ from fileglancer.user_worker import serialize_job_for_worker from fileglancer import sshkeys -from x2s3.utils import get_read_access_acl, get_nosuchbucket_response, get_error_response, generate_request_id +from x2s3.utils import get_read_access_acl, get_nosuchbucket_response, get_error_response, generate_request_id, get_list_xml from x2s3.client_file import FileProxyClient from x2s3.client import ObjectHandle @@ -218,6 +218,11 @@ def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optiona ) +# The /files space is served as a single S3 bucket named for its first path +# segment, which is how a path-style S3 client reads the share URL. +FILES_BUCKET_NAME = "files" + + # Regex: allow unreserved URI chars (RFC 3986), plus / for path separators and common safe chars _VALID_URL_PREFIX_RE = re.compile(r'^[A-Za-z0-9\-._~/!@$&\'()*+,;:=%]+$') @@ -424,52 +429,94 @@ async def _worker_exec(username: str, action: str, **kwargs): result.pop("_fd_mode", None) return result - def _resolve_proxy_info(sharing_key: str, captured_path: str) -> Tuple[dict | Response, str]: - """Resolve a sharing key to proxy info (mount_path, target_name, username, subpath). + def _resolve_sharing_key(sharing_key: str, key: str) -> dict | Response: + """Resolve a sharing key to the shared link's mount path, owner and name. - Returns (info_dict, subpath) on success, or (error_response, "") on failure. + `key` is only used to build the S3 error responses. """ - def try_strip_prefix(captured: str, prefix: str) -> str | None: - # Empty prefix (e.g. legacy records or FSP-root links): the entire - # captured path is the subpath. - if not prefix: - return captured - if captured == prefix: - return "" - if captured.startswith(prefix + "/"): - return captured[len(prefix) + 1:] - return None - with db.get_db_session(settings.db_url) as session: proxied_path = db.get_proxied_path_by_sharing_key(session, sharing_key) if not proxied_path: - return get_nosuchbucket_response(captured_path), "" + return get_nosuchbucket_response(key) # Treat legacy "." (FSP-root sentinel) as empty so old records that # were created before _normalize_proxied_path still resolve. stored_path = "" if proxied_path.path == "." else proxied_path.path stored_prefix = "" if proxied_path.url_prefix == "." else proxied_path.url_prefix - subpath = try_strip_prefix(captured_path, stored_prefix) - if subpath is None: - subpath = try_strip_prefix(captured_path, unquote(stored_prefix)) - if subpath is None: - return get_error_response(404, "NoSuchKey", f"Path mismatch for sharing key {sharing_key}", captured_path), "" - fsp = db.get_file_share_path(session, proxied_path.fsp_name) if not fsp: - return get_error_response(400, "InvalidArgument", f"File share path {proxied_path.fsp_name} not found", captured_path), "" + return get_error_response(400, "InvalidArgument", f"File share path {proxied_path.fsp_name} not found", key) expanded_mount_path = os.path.expanduser(fsp.mount_path) # For FSP-root links (empty path) use the mount path directly to # avoid a stray trailing slash in mount_path. mount_path = f"{expanded_mount_path}/{stored_path}" if stored_path else expanded_mount_path - target_name = captured_path.rsplit('/', 1)[-1] if captured_path else (os.path.basename(stored_path) or fsp.name) return { "mount_path": mount_path, - "target_name": target_name, "username": proxied_path.username, - }, subpath + "url_prefix": stored_prefix, + "default_name": os.path.basename(stored_path) or fsp.name, + } + + + def _link_virtual_prefix(link: dict, requested_prefix: str, base: str = "") -> str: + """The key prefix a client sees in front of a shared link's own files. + + Those leading key segments have no counterpart on disk, so x2s3 is + told about them as a virtual prefix. `base` is whatever precedes the + link name in the bucket's key space: the sharing key at the /files + bucket root, nothing when the sharing key is itself the bucket. + + A stored url_prefix may be percent-encoded, while a client that took + the name out of the share URL (as Neuroglancer does, which decodes the + path) asks for it decoded, so match whichever form was requested. + """ + # ponytail: a name that genuinely needs percent-encoding (a space, say) + # comes back out of the listing as '+', which won't resolve as a URL -- + # such links read fine but don't browse. The fix is to store url_prefix + # decoded, not to paper over it here. + stored = link["url_prefix"] + decoded = unquote(stored) + rest = requested_prefix[len(base):] if requested_prefix.startswith(base) else "" + name = decoded if decoded != stored and rest.startswith(decoded) else stored + return f"{base}{name}/" if name else base + + + def _resolve_proxy_info(sharing_key: str, captured_path: str) -> Tuple[dict | Response, str]: + """Resolve a sharing key to proxy info (mount_path, target_name, username, subpath). + + Returns (info_dict, subpath) on success, or (error_response, "") on failure. + """ + def try_strip_prefix(captured: str, prefix: str) -> str | None: + # Empty prefix (e.g. legacy records or FSP-root links): the entire + # captured path is the subpath. + if not prefix: + return captured + if captured == prefix: + return "" + if captured.startswith(prefix + "/"): + return captured[len(prefix) + 1:] + return None + + link = _resolve_sharing_key(sharing_key, captured_path) + if isinstance(link, Response): + return link, "" + + stored_prefix = link["url_prefix"] + subpath = try_strip_prefix(captured_path, stored_prefix) + if subpath is None: + subpath = try_strip_prefix(captured_path, unquote(stored_prefix)) + if subpath is None: + return get_error_response(404, "NoSuchKey", f"Path mismatch for sharing key {sharing_key}", captured_path), "" + + target_name = captured_path.rsplit('/', 1)[-1] if captured_path else link["default_name"] + return { + "mount_path": link["mount_path"], + "target_name": target_name, + "username": link["username"], + "url_prefix": stored_prefix, + }, subpath @asynccontextmanager @@ -1341,6 +1388,70 @@ async def get_neuroglancer_short_links(request: Request, return NeuroglancerShortLinkResponse(links=links) + async def _list_objects(link: dict, bucket_name: str, virtual_prefix: str, + continuation_token, delimiter, encoding_type, + fetch_owner, max_keys, prefix, start_after) -> Response: + """Run an S3 ListObjectsV2 against one shared link.""" + result = await _worker_exec(link["username"], "s3_list_objects", + mount_path=link["mount_path"], + target_name=bucket_name, + virtual_prefix=virtual_prefix, + continuation_token=continuation_token, + delimiter=delimiter, + encoding_type=encoding_type, + fetch_owner=fetch_owner, + max_keys=max_keys, + prefix=prefix, + start_after=start_after) + return Response(content=result["body"], media_type=result.get("media_type", "application/xml"), + status_code=result.get("status_code", 200)) + + + @app.get("/files/") + async def bucket_dispatcher(list_type: Optional[int] = Query(None, alias="list-type"), + continuation_token: Optional[str] = Query(None, alias="continuation-token"), + delimiter: Optional[str] = Query(None, alias="delimiter"), + encoding_type: Optional[str] = Query(None, alias="encoding-type"), + fetch_owner: Optional[bool] = Query(None, alias="fetch-owner"), + max_keys: Optional[int] = Query(1000, alias="max-keys"), + prefix: Optional[str] = Query(None, alias="prefix"), + start_after: Optional[str] = Query(None, alias="start-after")): + """S3 bucket root for data links. + + The whole /files space is one S3 bucket, so a key looks like + `{sharing_key}/{link name}/{path}`, and a client that wants to browse a + link asks the bucket root for everything under that prefix. That is how + Neuroglancer lists a directory: it takes the first path segment of the + share URL as the bucket and the rest as the prefix. + + Sharing keys are secret, so this never enumerates them. Without a + prefix that names one exactly, the answer is an empty listing -- + identical for a missing, partial or unknown key, so the endpoint can't + be probed for valid keys. Empty rather than an error is deliberate: + Neuroglancer remembers per-server whether S3 listing works at all, and + an error here would turn off browsing for the valid links too. + """ + if not list_type: + return get_error_response(404, "NoSuchKey", "The bucket root is not an object", "") + if list_type != 2: + return get_error_response(400, "InvalidArgument", f"Invalid list type {list_type}", "") + + prefix = prefix or '' + sharing_key = prefix.split('/', 1)[0] + link = _resolve_sharing_key(sharing_key, prefix) if sharing_key else None + if not isinstance(link, dict): + xml = get_list_xml([], [], Name=FILES_BUCKET_NAME, Prefix=prefix, Delimiter=delimiter, + MaxKeys=max_keys, EncodingType=encoding_type, KeyCount=0, + IsTruncated='false', ContinuationToken=continuation_token, + StartAfter=start_after) + return Response(content=xml, media_type="application/xml") + + return await _list_objects(link, FILES_BUCKET_NAME, + _link_virtual_prefix(link, prefix, f"{sharing_key}/"), + continuation_token, delimiter, encoding_type, + fetch_owner, max_keys, prefix, start_after) + + @app.get("/files/{sharing_key}/{path:path}") async def target_dispatcher(request: Request, sharing_key: str, @@ -1357,27 +1468,26 @@ async def target_dispatcher(request: Request, if 'acl' in request.query_params: return get_read_access_acl() - info, subpath = _resolve_proxy_info(sharing_key, path) - if isinstance(info, Response): - return info - if list_type: - if list_type == 2: - result = await _worker_exec(info["username"], "s3_list_objects", - mount_path=info["mount_path"], - target_name=info["target_name"], - continuation_token=continuation_token, - delimiter=delimiter, - encoding_type=encoding_type, - fetch_owner=fetch_owner, - max_keys=max_keys, - prefix=prefix, - start_after=start_after) - return Response(content=result["body"], media_type=result.get("media_type", "application/xml"), - status_code=result.get("status_code", 200)) - else: + # Reached when the sharing key is the bucket, i.e. a client pointed + # at /files as its endpoint. A bucket has no path of its own, so + # what was captured is ignored and the listing is driven by the + # prefix. Keys still carry the link name, so they resolve as URLs + # under the share URL. + link = _resolve_sharing_key(sharing_key, path) + if isinstance(link, Response): + return link + if list_type != 2: return get_error_response(400, "InvalidArgument", f"Invalid list type {list_type}", path) + return await _list_objects(link, sharing_key, + _link_virtual_prefix(link, prefix or ''), + continuation_token, delimiter, encoding_type, + fetch_owner, max_keys, prefix, start_after) else: + info, subpath = _resolve_proxy_info(sharing_key, path) + if isinstance(info, Response): + return info + range_header = request.headers.get("range") result = await _worker_exec( diff --git a/fileglancer/user_worker.py b/fileglancer/user_worker.py index fa004c8fa..375e6723c 100644 --- a/fileglancer/user_worker.py +++ b/fileglancer/user_worker.py @@ -887,6 +887,8 @@ def _action_s3_list_objects(request: dict, ctx: WorkerContext) -> dict: proxy_kwargs={"target_name": target_name}, path=mount_path, buffer_size=buffer_size, + # Key segments the client sees that have no counterpart on disk + virtual_prefix=request.get("virtual_prefix"), ) # list_objects_v2 is async def but does only sync I/O diff --git a/pixi.lock b/pixi.lock index 1025107b5..86da9274e 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,9 +172,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -341,8 +341,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -502,8 +502,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl @@ -666,8 +666,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl @@ -826,7 +826,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl @@ -1048,9 +1048,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -1266,8 +1266,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -1471,8 +1471,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl @@ -1679,8 +1679,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -1884,7 +1884,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl @@ -2145,9 +2145,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -2401,8 +2401,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -2652,8 +2652,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl @@ -2906,8 +2906,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl @@ -3154,7 +3154,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl @@ -3418,7 +3418,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -3676,7 +3676,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -3928,7 +3928,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -4181,8 +4181,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl @@ -4431,7 +4431,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl @@ -15828,7 +15828,7 @@ packages: - python-jose==3.5.0 - sqlalchemy==2.0.51 - uvicorn==0.38.0 - - x2s3==1.3.0 + - x2s3==1.5.1 - hatch==1.17.1 ; extra == 'release' - twine==6.2.0 ; extra == 'release' - coverage==7.15.1 ; python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'test' @@ -16201,6 +16201,31 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl + name: x2s3 + version: 1.5.1 + sha256: cc5612f17cbabadb0c13fa0f0c903376becf6d138abfe1e20e855c6edb6e5f95 + requires_dist: + - aiobotocore>=2.22 + - boto3>=1.37 + - botocore>=1.37 + - fastapi>=0.115 + - loguru>=0.7 + - pydantic>=2.11 + - pydantic-settings>=2.9 + - pydantic-settings-yaml>=0.2.0 + - python-dateutil>=2.9 + - starlette>=0.46 + - uvicorn>=0.34 + - uvloop>=0.21 ; sys_platform != 'win32' + - jinja2>=3.1 + - pytest>=8.3 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-html ; extra == 'test' + - httpx>=0.28 ; extra == 'test' + - build ; extra == 'release' + - twine ; extra == 'release' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl name: yarl version: 1.24.5 @@ -16243,31 +16268,6 @@ packages: requires_dist: - typing-extensions>=4.1.0 ; python_full_version < '3.11' requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.0-py3-none-any.whl - name: x2s3 - version: 1.3.0 - sha256: deb292575bd242004a903ba1bd1892d6528a91f501d348b932322fbf4f4131fe - requires_dist: - - aiobotocore>=2.22 - - boto3>=1.37 - - botocore>=1.37 - - fastapi>=0.115 - - loguru>=0.7 - - pydantic>=2.11 - - pydantic-settings>=2.9 - - pydantic-settings-yaml>=0.2.0 - - python-dateutil>=2.9 - - starlette>=0.46 - - uvicorn>=0.34 - - uvloop>=0.21 ; sys_platform != 'win32' - - jinja2>=3.1 - - pytest>=8.3 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-html ; extra == 'test' - - httpx>=0.28 ; extra == 'test' - - build ; extra == 'release' - - twine ; extra == 'release' - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl name: propcache version: 0.5.2 diff --git a/pyproject.toml b/pyproject.toml index 91ad8e44b..f786a8f52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "sqlalchemy ==2.0.51", "packaging ==26.2", "uvicorn ==0.38.0", - "x2s3 ==1.3.0", + "x2s3 ==1.5.1", "py-cluster-api ==0.8.0" ] @@ -210,7 +210,7 @@ fileglancer = { path = ".", editable = true } # have no [tool.pixi.*] conda mirror to restore a range from. Not read by # pixi or pip -- sync_pyproject_versions.py is the only consumer. [tool.sync-pypi-versions.ranges] -x2s3 = ">=1.3.0,<2" +x2s3 = ">=1.5.1,<2" py-cluster-api = ">=0.8.0,<0.9" build = ">=1.3.0,<2" diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 9eb855959..1f5587c27 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1919,3 +1919,123 @@ def test_viewers_config_file_missing(temp_dir): dispose_engine(db_url) fileglancer.settings.get_settings = original_get_settings fileglancer.database.get_settings = original_get_settings + + +# --- S3 bucket-root listing of data links ----------------------------------- + +def _make_data_link(test_client, temp_dir): + """Create a data link over a small tree, returning (sharing_key, url_prefix).""" + os.makedirs(os.path.join(temp_dir, "test_proxied_path", "sub"), exist_ok=True) + with open(os.path.join(temp_dir, "test_proxied_path", "a.txt"), "w") as f: + f.write("hello link") + response = test_client.post("/api/proxied-path?fsp_name=tempdir&path=test_proxied_path") + assert response.status_code == 200 + data = response.json() + return data["sharing_key"], data["url_prefix"] + + +def _listing(test_client, url): + from x2s3.utils import parse_xml + response = test_client.get(url) + assert response.status_code == 200, response.text + root = parse_xml(response.text) + assert root.tag == "ListBucketResult" + return root + + +def _keys(root): + return ([c.find('Key').text for c in root.findall('Contents')], + [cp.find('Prefix').text for cp in root.findall('CommonPrefixes')]) + + +def test_bucket_root_lists_data_link(test_client, temp_dir): + """Browsing a data link means listing the /files bucket under its key prefix. + + That is how Neuroglancer lists a directory over plain https: it reads the + first path segment of the share URL as the bucket and the rest as a prefix. + """ + sharing_key, url_prefix = _make_data_link(test_client, temp_dir) + prefix = f"{sharing_key}/{url_prefix}/" + + root = _listing(test_client, f"/files/?list-type=2&prefix={prefix}&delimiter=/&encoding-type=url") + assert root.find('Name').text == "files" + assert root.find('Prefix').text == prefix + keys, common_prefixes = _keys(root) + assert keys == [f"{prefix}a.txt"] + assert common_prefixes == [f"{prefix}sub/"] + + # The keys are relative to the bucket, so they resolve as share URLs + response = test_client.get(f"/files/{keys[0]}") + assert response.status_code == 200 + assert response.text == "hello link" + + +def test_bucket_root_lists_link_name_as_folder(test_client, temp_dir): + """The link name is not on disk, but it browses like a folder.""" + sharing_key, url_prefix = _make_data_link(test_client, temp_dir) + + # As with a real folder, a prefix is truncated at the next delimiter + cases = [(f"{sharing_key}", f"{sharing_key}/"), + (f"{sharing_key}/", f"{sharing_key}/{url_prefix}/"), + (f"{sharing_key}/{url_prefix[:3]}", f"{sharing_key}/{url_prefix}/")] + for prefix, expected in cases: + root = _listing(test_client, f"/files/?list-type=2&prefix={prefix}&delimiter=/") + keys, common_prefixes = _keys(root) + assert keys == [] + assert common_prefixes == [expected] + + +def test_bucket_root_does_not_enumerate_sharing_keys(test_client, temp_dir): + """Sharing keys are secret: the bucket root never gives one away. + + A missing, partial or unknown key all produce the same empty listing, so + the endpoint can't be probed for valid keys. + """ + sharing_key, _ = _make_data_link(test_client, temp_dir) + + for prefix in ["", sharing_key[:6], "zzzzzzzzzzzz", "zzzzzzzzzzzz/data/"]: + root = _listing(test_client, f"/files/?list-type=2&prefix={prefix}&delimiter=/") + keys, common_prefixes = _keys(root) + assert keys == [], f"leaked contents for prefix {prefix!r}" + assert common_prefixes == [], f"leaked a sharing key for prefix {prefix!r}" + assert root.find('KeyCount').text == '0' + assert root.find('IsTruncated').text == 'false' + + # Listing has to stay a valid, successful S3 response even when empty: + # Neuroglancer caches "this server can't list" per origin on an error, + # which would turn off browsing for the valid links too. + assert test_client.get("/files/?list-type=2").status_code == 200 + # Without a trailing slash the request still reaches the bucket root + assert test_client.get("/files?list-type=2").status_code == 200 + + +def test_bucket_root_is_not_an_object(test_client): + """The bucket root is only a listing endpoint, never the SPA or a file.""" + from x2s3.utils import parse_xml + response = test_client.get("/files/") + assert response.status_code == 404 + assert parse_xml(response.text).find('Code').text == 'NoSuchKey' + assert test_client.get("/files/?list-type=1").status_code == 400 + + +def test_sharing_key_as_bucket_agrees_with_bucket_root(test_client, temp_dir): + """A client can also treat the sharing key itself as the bucket. + + Neuroglancer races both readings of a share URL and keeps whichever + answers first, so the two must resolve to the same files. + """ + sharing_key, url_prefix = _make_data_link(test_client, temp_dir) + + root = _listing(test_client, f"/files/{sharing_key}/?list-type=2&prefix={url_prefix}/&delimiter=/") + assert root.find('Name').text == sharing_key + keys, common_prefixes = _keys(root) + assert keys == [f"{url_prefix}/a.txt"] + assert common_prefixes == [f"{url_prefix}/sub/"] + + bucket_root = _listing( + test_client, f"/files/?list-type=2&prefix={sharing_key}/{url_prefix}/&delimiter=/") + root_keys, root_common = _keys(bucket_root) + # Both readings name the same URLs: the sharing key is the bucket in one + # and the first key segment in the other. + assert [f"/files/{sharing_key}/{k}" for k in keys] == [f"/files/{k}" for k in root_keys] + assert [f"/files/{sharing_key}/{p}" for p in common_prefixes] == [f"/files/{p}" for p in root_common] From d0da456e498656a239efce17d91eefe2d97b6d1f Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 28 Aug 2026 09:12:16 -0400 Subject: [PATCH 40/43] fix: keep names that need encoding readable through a listing x2s3 reported a space in a key as '+', which no client turns back into a space -- boto3 unquotes and Neuroglancer calls decodeURIComponent -- so a key taken from a listing named a file that did not exist. Any file or directory with a space in its name listed but would not open. Fixed in x2s3 1.5.2, along with max-keys and continuation tokens being ignored inside a virtual prefix. Covered by a test that lists a link whose name and contents both contain spaces, for the stored and the decoded form of the link name, and reads each reported key back to prove it resolves. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/server.py | 4 -- pixi.lock | 92 ++++++++++++++++++++--------------------- pyproject.toml | 4 +- tests/test_endpoints.py | 31 ++++++++++++++ 4 files changed, 79 insertions(+), 52 deletions(-) diff --git a/fileglancer/server.py b/fileglancer/server.py index 312a37b43..49ee09403 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -472,10 +472,6 @@ def _link_virtual_prefix(link: dict, requested_prefix: str, base: str = "") -> s the name out of the share URL (as Neuroglancer does, which decodes the path) asks for it decoded, so match whichever form was requested. """ - # ponytail: a name that genuinely needs percent-encoding (a space, say) - # comes back out of the listing as '+', which won't resolve as a URL -- - # such links read fine but don't browse. The fix is to store url_prefix - # decoded, not to paper over it here. stored = link["url_prefix"] decoded = unquote(stored) rest = requested_prefix[len(base):] if requested_prefix.startswith(base) else "" diff --git a/pixi.lock b/pixi.lock index 86da9274e..3f354474b 100644 --- a/pixi.lock +++ b/pixi.lock @@ -172,10 +172,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -341,9 +341,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -502,10 +502,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -666,9 +666,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -826,9 +826,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -1048,11 +1048,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -1266,10 +1266,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -1471,11 +1471,11 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -1679,10 +1679,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -1884,10 +1884,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -2145,10 +2145,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -2401,9 +2401,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -2652,10 +2652,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -2906,9 +2906,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -3154,9 +3154,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -3418,8 +3418,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl @@ -3676,8 +3676,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -3928,8 +3928,8 @@ environments: - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -4181,9 +4181,9 @@ environments: - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl @@ -4431,10 +4431,10 @@ environments: - pypi: https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl + - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl @@ -15828,7 +15828,7 @@ packages: - python-jose==3.5.0 - sqlalchemy==2.0.51 - uvicorn==0.38.0 - - x2s3==1.5.1 + - x2s3==1.5.2 - hatch==1.17.1 ; extra == 'release' - twine==6.2.0 ; extra == 'release' - coverage==7.15.1 ; python_full_version == '3.12.*' and platform_machine == 'x86_64' and sys_platform == 'darwin' and extra == 'test' @@ -16201,31 +16201,6 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/97/c7/829d78ada82f24def3ae9ad03764ae9cc31e0c3efd7c391e3be96660aab9/x2s3-1.5.1-py3-none-any.whl - name: x2s3 - version: 1.5.1 - sha256: cc5612f17cbabadb0c13fa0f0c903376becf6d138abfe1e20e855c6edb6e5f95 - requires_dist: - - aiobotocore>=2.22 - - boto3>=1.37 - - botocore>=1.37 - - fastapi>=0.115 - - loguru>=0.7 - - pydantic>=2.11 - - pydantic-settings>=2.9 - - pydantic-settings-yaml>=0.2.0 - - python-dateutil>=2.9 - - starlette>=0.46 - - uvicorn>=0.34 - - uvloop>=0.21 ; sys_platform != 'win32' - - jinja2>=3.1 - - pytest>=8.3 ; extra == 'test' - - pytest-cov ; extra == 'test' - - pytest-html ; extra == 'test' - - httpx>=0.28 ; extra == 'test' - - build ; extra == 'release' - - twine ; extra == 'release' - requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl name: yarl version: 1.24.5 @@ -16311,6 +16286,31 @@ packages: - multidict>=4.0 - propcache>=0.2.1 requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl + name: x2s3 + version: 1.5.2 + sha256: 2165f06d8e262a5b14f04c68cba165f3c2451776cb52f5e602e39d6143bafc69 + requires_dist: + - aiobotocore>=2.22 + - boto3>=1.37 + - botocore>=1.37 + - fastapi>=0.115 + - loguru>=0.7 + - pydantic>=2.11 + - pydantic-settings>=2.9 + - pydantic-settings-yaml>=0.2.0 + - python-dateutil>=2.9 + - starlette>=0.46 + - uvicorn>=0.34 + - uvloop>=0.21 ; sys_platform != 'win32' + - jinja2>=3.1 + - pytest>=8.3 ; extra == 'test' + - pytest-cov ; extra == 'test' + - pytest-html ; extra == 'test' + - httpx>=0.28 ; extra == 'test' + - build ; extra == 'release' + - twine ; extra == 'release' + requires_python: '>=3.12' - pypi: https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl name: multidict version: 6.7.1 diff --git a/pyproject.toml b/pyproject.toml index f786a8f52..5c2185674 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ dependencies = [ "sqlalchemy ==2.0.51", "packaging ==26.2", "uvicorn ==0.38.0", - "x2s3 ==1.5.1", + "x2s3 ==1.5.2", "py-cluster-api ==0.8.0" ] @@ -210,7 +210,7 @@ fileglancer = { path = ".", editable = true } # have no [tool.pixi.*] conda mirror to restore a range from. Not read by # pixi or pip -- sync_pyproject_versions.py is the only consumer. [tool.sync-pypi-versions.ranges] -x2s3 = ">=1.5.1,<2" +x2s3 = ">=1.5.2,<2" py-cluster-api = ">=0.8.0,<0.9" build = ">=1.3.0,<2" diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 1f5587c27..639f66737 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -2039,3 +2039,34 @@ def test_sharing_key_as_bucket_agrees_with_bucket_root(test_client, temp_dir): # and the first key segment in the other. assert [f"/files/{sharing_key}/{k}" for k in keys] == [f"/files/{k}" for k in root_keys] assert [f"/files/{sharing_key}/{p}" for p in common_prefixes] == [f"/files/{p}" for p in root_common] + + +def test_bucket_root_lists_names_with_spaces(test_client, temp_dir): + """Names that need encoding have to survive the listing. + + A client reads a key out of the listing and asks for it back, so a space + has to come back as %20 (which decodes to a space) rather than '+' (which + does not). + """ + os.makedirs(os.path.join(temp_dir, "my data", "sub dir"), exist_ok=True) + with open(os.path.join(temp_dir, "my data", "my file.txt"), "w") as f: + f.write("spaced") + response = test_client.post("/api/proxied-path", params={"fsp_name": "tempdir", "path": "my data"}) + assert response.status_code == 200 + data = response.json() + sharing_key, url_prefix = data["sharing_key"], data["url_prefix"] + assert url_prefix == "my%20data", "link names are stored percent-encoded" + + # The link name reaches the server either as stored or decoded, depending + # on how far the client unwound the share URL, and both have to list + for name in [url_prefix, "my data"]: + root = _listing(test_client, "/files/?list-type=2&delimiter=/&encoding-type=url" + f"&prefix={quote(f'{sharing_key}/{name}/')}") + keys, common_prefixes = _keys(root) + assert len(keys) == 1 and keys[0].endswith("/my%20file.txt"), keys + assert len(common_prefixes) == 1 and common_prefixes[0].endswith("/sub%20dir/"), common_prefixes + + # Whatever the listing reported, asking for it back reaches the file + response = test_client.get(f"/files/{keys[0]}") + assert response.status_code == 200 + assert response.text == "spaced" From a3727f7121b5500e65b949672859da782c35b3f4 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 28 Aug 2026 13:18:08 -0400 Subject: [PATCH 41/43] feat: add a JSON logging mode for Kibana Response times were only ever in the text access log, where they could not be aggregated. Setting log_format: json (or FGC_LOG_FORMAT=json) now writes one Elastic Common Schema object per line to stdout, which a shipper can parse straight off the container's logs, so p95/p99 can be charted from event.duration and broken down by endpoint, user or status code. Text remains the default and stays human-readable. The access log middleware keeps emitting the same line and additionally binds the same facts as ECS fields, which text mode ignores. Each request also gets an id, returned in the x-request-id header and bound to every line logged while serving it, so a bug report quoting the id finds the whole request. Output configuration lives in a new fileglancer.logconf, kept free of Fileglancer imports so the setuid user worker can configure its logging without pulling in the server's auth stack. Workers inherit the format over FGC_LOG_FORMAT. event.duration here is time-to-first-byte, not transfer time, because BaseHTTPMiddleware returns as soon as the response starts. That is fine for the API; x2s3 serves the bulk data and does measure full transfer time. Charting download latency would mean rewriting the middleware as pure ASGI, the way x2s3's is. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 1 + docs/StructuredLogging.md | 52 ++++++++++++++++ docs/config.yaml.template | 7 +++ fileglancer/cli.py | 5 +- fileglancer/log.py | 86 +++++++++++++++++++-------- fileglancer/logconf.py | 118 +++++++++++++++++++++++++++++++++++++ fileglancer/server.py | 8 +-- fileglancer/settings.py | 3 + fileglancer/user_worker.py | 32 ++++------ fileglancer/worker_pool.py | 5 +- tests/test_log.py | 88 +++++++++++++++++++++++++++ tests/test_worker.py | 2 + 12 files changed, 353 insertions(+), 54 deletions(-) create mode 100644 docs/StructuredLogging.md create mode 100644 fileglancer/logconf.py diff --git a/README.md b/README.md index 460b6f44c..0b1253c24 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Fileglancer has a React front-end and a FastAPI backend. Uvicorn is used to mana - [User guide](https://janeliascicomp.github.io/fileglancer-docs/) - [Developer guide](docs/Development.md) +- [Structured logging](docs/StructuredLogging.md) ## Related repositories diff --git a/docs/StructuredLogging.md b/docs/StructuredLogging.md new file mode 100644 index 000000000..5e9543280 --- /dev/null +++ b/docs/StructuredLogging.md @@ -0,0 +1,52 @@ +# Structured Logging + +Setting `log_format: json` (or `FGC_LOG_FORMAT=json`) makes Fileglancer write one JSON object per line to stdout instead of human-readable text. The field names follow the [Elastic Common Schema](https://www.elastic.co/guide/en/ecs/current/index.html) (ECS), so Kibana's built-in visualizations understand them without any mapping work. + +The default remains `text`; nothing changes unless you turn this on. Turn it on in production, leave it off for development. + +## What gets logged + +Every log record carries `@timestamp`, `log.level`, `log.logger`, `message`, `service.name`, `service.version` and `process.pid`. Records emitted while serving a request also carry `trace.id`, which is returned to the client in the `x-request-id` response header — so a user reporting a problem can quote an id that pulls up every line for that request, including the ones logged inside the handler. + +One access line is logged per request, tagged `event.dataset: fileglancer.access`, with: + +| Field | Notes | +|---|---| +| `event.duration` | Request duration in **nanoseconds** (the ECS unit). | +| `http.request.method`, `http.version` | | +| `http.response.status_code` | | +| `http.response.body.bytes` | Present when the response declared a Content-Length. | +| `url.path`, `url.query`, `url.domain` | Path is logged as the client encoded it. | +| `client.ip`, `client.port` | Corrected for the reverse proxy when running behind one. | +| `user_agent.original` | | +| `user.name` | Session or API-token user, absent when unauthenticated. | +| `labels.token_id` | Present on API-token requests; the id to revoke. | +| `labels.endpoint` | Handler function name. Group latency by this — raw paths have unbounded cardinality and are useless to aggregate over. | +| `error.type`, `error.message`, `error.stack_trace` | On records logged with an exception. | + +Per-user worker subprocesses log in the same format. In text mode their output is forwarded through the parent tagged `[worker:username]`; in JSON mode they write their own records straight to the shared stdout, identifiable by `process.pid`. + +## Shipping to Elasticsearch + +Fileglancer logs to stdout, so a shipper reading the container's log files needs only to parse the JSON. For Filebeat: + +```yaml +filebeat.inputs: + - type: container + paths: + - /var/lib/docker/containers/*/*.log + json.keys_under_root: true + json.add_error_key: true +``` + +Since ECS field names are written as dotted keys, Elasticsearch expands them into the usual nested ECS document on ingest. + +## Kibana + +`event.duration` is a plain number, so a percentile aggregation over it gives p95/p99 directly. Set the field's format to *Duration* (input: nanoseconds) in the data view to have Kibana render it readably. Break it down by `labels.endpoint` to find a slow API route, by `user.name` to find a heavy user, or by `http.response.status_code` for error rates. + +## Known limitation + +`event.duration` is measured until the response starts, not until the last byte is sent, so file downloads are recorded as time-to-first-byte rather than transfer time. (x2s3, which serves the bulk data, does measure full transfer time.) Charting download latency here would mean rewriting the access log middleware as pure ASGI, the way x2s3's is. + +Uvicorn logs two lines ("Started server process", "Waiting for application startup") before it imports the app, so those stay plain text on every start. Everything after that is JSON. diff --git a/docs/config.yaml.template b/docs/config.yaml.template index 5094211ee..d5c20e746 100644 --- a/docs/config.yaml.template +++ b/docs/config.yaml.template @@ -3,6 +3,13 @@ # log_level: INFO +# +# Log output format: 'text' for human-readable logs, or 'json' for one +# ECS-shaped JSON object per line on stdout, ready for a log shipper to +# forward to Elasticsearch/Kibana. See docs/StructuredLogging.md. +# +log_format: text + # # Database URL (runtime - unprivileged user) # For SQLite: sqlite:///fileglancer.db diff --git a/fileglancer/cli.py b/fileglancer/cli.py index 6bff4efdb..d7605fdbe 100644 --- a/fileglancer/cli.py +++ b/fileglancer/cli.py @@ -159,8 +159,9 @@ def start(host, port, reload, workers, ssl_keyfile, ssl_certfile, from fileglancer.settings import get_settings, reload_settings settings = get_settings() log_level = settings.log_level - logger.remove() - logger.add(lambda msg: click.echo(msg, nl=False), level=log_level, colorize=True) + from fileglancer.logconf import configure_logging + configure_logging(log_level, settings.log_format, + text_sink=lambda msg: click.echo(msg, nl=False), colorize=True) # Set up default database location if not already configured if 'FGC_DB_URL' not in os.environ: diff --git a/fileglancer/log.py b/fileglancer/log.py index 4c2bf1600..08d2d0929 100644 --- a/fileglancer/log.py +++ b/fileglancer/log.py @@ -4,8 +4,12 @@ This middleware logs HTTP access information including authenticated username. It replaces Uvicorn's default access logger to provide more detailed logging with application-level authentication context. + +Each request is logged as one human-readable line, with the same facts also +attached as Elastic Common Schema fields, which the JSON log format in +``fileglancer.logconf`` emits for Kibana. """ -import logging +import secrets import time from typing import Callable @@ -15,6 +19,7 @@ from starlette.types import ASGIApp from fileglancer import auth +from fileglancer.logconf import SERVICE_NAME from fileglancer.settings import Settings @@ -29,24 +34,6 @@ def _log_safe(value: str) -> str: return value.encode("unicode_escape").decode("ascii") -def disable_uvicorn_access_log(): - """Silence Uvicorn's own access logger, which AccessLogMiddleware replaces. - - Left enabled, every request is logged twice: once here with the username and - duration, once by Uvicorn without them. Uvicorn's ``--no-access-log`` does - the same thing from the outside, but it has to be remembered on every launch - command, and it was missing from several. Doing it where the middleware is - installed covers any way the app is started. - - Matches what Uvicorn's own ``access_log=False`` does. Safe to call more than - once, and safe at import time: Uvicorn configures logging before it imports - the app, including in each --reload/--workers subprocess. - """ - access_logger = logging.getLogger("uvicorn.access") - access_logger.handlers = [] - access_logger.propagate = False - - class AccessLogMiddleware(BaseHTTPMiddleware): """ Middleware that logs HTTP access information with username when available. @@ -65,7 +52,7 @@ def __init__(self, app: ASGIApp, settings: Settings): async def dispatch(self, request: Request, call_next: Callable) -> Response: """Process request and log access information""" - start_time = time.time() + start_time = time.perf_counter() # Extract username from session (if authenticated) username = "-" @@ -77,8 +64,14 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: # Silently handle any authentication errors - user is just not authenticated pass - # Process the request - response = await call_next(request) + # Bind an id for this request as ECS trace.id, so every line logged + # while serving it can be grouped with its access log line in Kibana. + # It also goes back to the client as a response header, so a bug report + # quoting it is enough to find the request. + request_id = secrets.token_hex(8) + with logger.contextualize(**{"trace.id": request_id}): + response = await call_next(request) + response.headers["x-request-id"] = request_id # Token-authenticated requests have no session cookie, so the lookup # above found nothing and username is still '-'. get_user_from_token @@ -94,7 +87,12 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: else token_username) # Calculate request duration - duration_ms = (time.time() - start_time) * 1000 + # ponytail: BaseHTTPMiddleware returns once the response starts, so for + # streamed downloads this is time-to-first-byte, not transfer time. + # Rewrite as pure ASGI (see x2s3's AccessLogMiddleware) if download + # latency ever needs charting. + duration_s = time.perf_counter() - start_time + duration_ms = duration_s * 1000 # Extract client information client_host = request.client.host if request.client else "unknown" @@ -128,12 +126,48 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: f"{response.status_code} - {duration_ms:.2f}ms" ) + # The same facts as the message above, keyed by ECS field name. Text + # mode ignores them; JSON mode merges them into the logged object, + # where event.duration is what p95/p99 aggregations run on. + fields = { + "event.dataset": f"{SERVICE_NAME}.access", + # ECS measures event.duration in nanoseconds. + "event.duration": int(duration_s * 1_000_000_000), + "trace.id": request_id, + "http.request.method": request.method, + "http.response.status_code": response.status_code, + "http.version": http_version, + "url.path": _log_safe(path), + "client.ip": client_host, + "client.port": client_port, + } + if request.url.query: + fields["url.query"] = _log_safe(request.url.query) + if username != "-": + fields["user.name"] = token_username or username + if token_username and token_id: + fields["labels.token_id"] = token_id + # The handler function name, so latency can be grouped by endpoint. + # Raw paths are unbounded cardinality and useless to aggregate over. + endpoint = request.scope.get("endpoint") + if endpoint is not None: + fields["labels.endpoint"] = getattr(endpoint, "__name__", str(endpoint)) + content_length = response.headers.get("content-length") + if content_length is not None: + fields["http.response.body.bytes"] = int(content_length) + for header, field in (("user-agent", "user_agent.original"), + ("host", "url.domain")): + value = request.headers.get(header) + if value: + fields[field] = _log_safe(value) + # Log at INFO level for successful requests, WARNING for client errors, ERROR for server errors + log = logger.bind(**fields) if 200 <= response.status_code < 400: - logger.info(log_message) + log.info(log_message) elif 400 <= response.status_code < 500: - logger.warning(log_message) + log.warning(log_message) else: - logger.error(log_message) + log.error(log_message) return response diff --git a/fileglancer/logconf.py b/fileglancer/logconf.py new file mode 100644 index 000000000..e78a48c25 --- /dev/null +++ b/fileglancer/logconf.py @@ -0,0 +1,118 @@ +""" +Log output configuration: human-readable text (the default) or JSON. + +Setting ``log_format: json`` (or ``FGC_LOG_FORMAT=json``) turns every record +into a single-line JSON object using Elastic Common Schema field names, which +a log shipper can parse straight off stdout and hand to Kibana. ECS fields are +written as dotted keys ("http.response.status_code") rather than nested dicts: +Elasticsearch expands them on ingest, so the indexed document is the same one +without any dict-building here. + +Kept free of Fileglancer imports so the setuid user worker can configure its +logging without pulling in the server's auth stack. +""" +import json +import logging +import sys +import traceback +from datetime import timezone +from importlib.metadata import PackageNotFoundError, version + +from loguru import logger + +SERVICE_NAME = "fileglancer" + +try: + SERVICE_VERSION = version("fileglancer") +except PackageNotFoundError: # running from a source tree that was never installed + SERVICE_VERSION = "unknown" + + +def json_sink(message): + """Write one loguru record as one line of ECS-shaped JSON on stdout.""" + record = message.record + payload = { + "@timestamp": record["time"].astimezone(timezone.utc) + .isoformat(timespec="milliseconds").replace("+00:00", "Z"), + "log.level": record["level"].name.lower(), + "log.logger": record["name"], + "message": record["message"], + "service.name": SERVICE_NAME, + "service.version": SERVICE_VERSION, + "process.pid": record["process"].id, + } + # Anything bound with logger.bind()/contextualize() is already keyed by its + # ECS name, so it merges straight in. + payload.update(record["extra"]) + + exception = record["exception"] + if exception: + payload["error.type"] = getattr(exception.type, "__name__", "Exception") + payload["error.message"] = str(exception.value) + payload["error.stack_trace"] = "".join(traceback.format_exception( + exception.type, exception.value, exception.traceback)) + + sys.stdout.write(json.dumps(payload, default=str) + "\n") + sys.stdout.flush() + + +class InterceptHandler(logging.Handler): + """Route stdlib logging (uvicorn, sqlalchemy) into loguru. + + Only installed in JSON mode, so that a shipper sees one format on the + stream instead of JSON lines mixed with uvicorn's plain text. + """ + + def emit(self, record: logging.LogRecord) -> None: + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + frame, depth = logging.currentframe(), 2 + while frame and frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + # Report the stdlib logger's own name rather than the frame loguru + # would infer, which is always the logging module itself. + logger.opt(depth=depth, exception=record.exc_info).bind( + **{"log.logger": record.name}).log(level, record.getMessage()) + + +def configure_logging(log_level: str = "INFO", log_format: str = "text", + text_sink=sys.stderr, colorize: bool = None): + """Point loguru at the human-readable sink, or at the ECS JSON sink. + + ``text_sink``/``colorize`` only apply to text mode; the CLI passes a click + sink so its output stays on the click stream. + """ + logger.remove() + if log_format == "json": + logger.add(json_sink, level=log_level, format="{message}") + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) + # Uvicorn installs its own handlers and turns off propagation, so its + # lines would stay plain text on a stream that is otherwise all JSON. + # Hand them to the root logger, which now goes through loguru. + for name in ("uvicorn", "uvicorn.error", "uvicorn.asgi"): + uvicorn_logger = logging.getLogger(name) + uvicorn_logger.handlers = [] + uvicorn_logger.propagate = True + else: + logger.add(text_sink, level=log_level, colorize=colorize) + + +def disable_uvicorn_access_log(): + """Silence Uvicorn's own access logger, which AccessLogMiddleware replaces. + + Left enabled, every request is logged twice: once here with the username and + duration, once by Uvicorn without them. Uvicorn's ``--no-access-log`` does + the same thing from the outside, but it has to be remembered on every launch + command, and it was missing from several. Doing it where the middleware is + installed covers any way the app is started. + + Matches what Uvicorn's own ``access_log=False`` does. Safe to call more than + once, and safe at import time: Uvicorn configures logging before it imports + the app, including in each --reload/--workers subprocess. + """ + access_logger = logging.getLogger("uvicorn.access") + access_logger.handlers = [] + access_logger.propagate = False diff --git a/fileglancer/server.py b/fileglancer/server.py index 49ee09403..d4f3bf460 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -42,7 +42,8 @@ from fileglancer.issues import create_jira_ticket, get_jira_ticket_details, delete_jira_ticket from fileglancer.utils import format_timestamp, guess_content_type, parse_range_header, make_etag, parse_http_date_to_epoch from fileglancer.filestore import Filestore, RootCheckError -from fileglancer.log import AccessLogMiddleware, disable_uvicorn_access_log +from fileglancer.log import AccessLogMiddleware +from fileglancer.logconf import configure_logging, disable_uvicorn_access_log from fileglancer.worker_pool import ( WorkerPool, WorkerError, WorkerDead, prepare_worker_request) from fileglancer.user_worker import serialize_job_for_worker @@ -518,9 +519,8 @@ def try_strip_prefix(captured: str, prefix: str) -> str | None: @asynccontextmanager async def lifespan(app: FastAPI): - # Configure logging based on the log level in the settings - logger.remove() - logger.add(sys.stderr, level=settings.log_level) + # Configure logging based on the log level/format in the settings + configure_logging(settings.log_level, settings.log_format) # use_access_flags requires root + non-CLI mode. Workers themselves # are used in any server mode (CLI mode runs in-process). diff --git a/fileglancer/settings.py b/fileglancer/settings.py index b22b0e2ea..9a6237172 100644 --- a/fileglancer/settings.py +++ b/fileglancer/settings.py @@ -61,6 +61,9 @@ class Settings(BaseSettings): """ log_level: str = 'INFO' + # 'text' for human-readable logs, 'json' for one ECS-shaped JSON object + # per line, ready for a log shipper to forward to Elasticsearch/Kibana. + log_format: str = 'text' db_url: str = 'sqlite:///fileglancer.db' db_admin_url: Optional[str] = None diff --git a/fileglancer/user_worker.py b/fileglancer/user_worker.py index 375e6723c..58164148b 100644 --- a/fileglancer/user_worker.py +++ b/fileglancer/user_worker.py @@ -41,6 +41,8 @@ from loguru import logger +from fileglancer.logconf import InterceptHandler, configure_logging + # Length-prefix format: 4-byte big-endian unsigned int _HEADER_FMT = "!I" @@ -1279,31 +1281,19 @@ def main(): # Set up orphan prevention _set_pdeathsig() - # Configure logging + # Configure logging. In text mode this goes to stderr, which the parent + # forwards into its own log tagged with the worker's username. In JSON + # mode it goes to the inherited stdout instead, so each record reaches the + # shipper as the complete JSON object the worker wrote, rather than being + # buried inside the parent's message string. log_level = os.environ.get("FGC_LOG_LEVEL", "INFO").upper() + log_format = os.environ.get("FGC_LOG_FORMAT", "text") + configure_logging(log_level, log_format) - # Use loguru for worker logging, output to stderr - logger.remove() - logger.add(sys.stderr, level=log_level) - - # Route stdlib logging (used by cluster_api) into loguru so a + # Route stdlib logging (used by cluster_api) into loguru so a # single configuration controls levels and formatting, and # loguru-only levels like TRACE/SUCCESS work without translation. - class _InterceptHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - frame, depth = logging.currentframe(), 2 - while frame and frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - logger.opt(depth=depth, exception=record.exc_info).log( - level, record.getMessage() - ) - - logging.basicConfig(handlers=[_InterceptHandler()], level=0, force=True) + logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) # Get the socket fd from environment fd = int(os.environ["FGC_WORKER_FD"]) diff --git a/fileglancer/worker_pool.py b/fileglancer/worker_pool.py index c63934835..561c4a16c 100644 --- a/fileglancer/worker_pool.py +++ b/fileglancer/worker_pool.py @@ -144,7 +144,8 @@ def prepare_worker_request(request: dict, db_url: str) -> dict: def _build_worker_env(base_env: dict, home_dir: str, log_level: str, - child_fd: int, passthrough: Optional[list] = None) -> dict: + child_fd: int, passthrough: Optional[list] = None, + log_format: str = "text") -> dict: """Build the environment for a worker subprocess (allowlist). Keeps only allowlisted variables (see the module constants) plus any @@ -171,6 +172,7 @@ def _allowed(key: str) -> bool: env = {k: v for k, v in base_env.items() if _allowed(k)} env["HOME"] = home_dir env["FGC_LOG_LEVEL"] = log_level + env["FGC_LOG_FORMAT"] = log_format env["FGC_WORKER_FD"] = str(child_fd) return env @@ -463,6 +465,7 @@ async def _spawn_worker(self, username: str) -> UserWorker: env = _build_worker_env( os.environ, home_dir, self.settings.log_level, child_sock.fileno(), passthrough=self.settings.apps.worker_env_passthrough, + log_format=self.settings.log_format, ) logger.info( diff --git a/tests/test_log.py b/tests/test_log.py index 22cc402df..7a75c816d 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -1,15 +1,29 @@ """Tests for the access log middleware.""" import asyncio +import json +import logging +import pytest from fastapi import FastAPI, Request from fastapi.testclient import TestClient from loguru import logger from fileglancer.log import AccessLogMiddleware +from fileglancer.logconf import configure_logging from fileglancer.settings import Settings +@pytest.fixture(autouse=True) +def restore_logging(): + """JSON mode reroutes stdlib logging globally; undo it between tests.""" + root = logging.getLogger() + handlers, level = root.handlers[:], root.level + yield + configure_logging("INFO", "text") + root.handlers, root.level = handlers, level + + def _app_and_lines(): """A minimal app behind AccessLogMiddleware, plus the lines it logs.""" app = FastAPI() @@ -131,3 +145,77 @@ def test_unauthenticated_requests_still_log_a_dash(): assert len(lines) == 1, lines assert "[-]" in lines[0], lines[0] + + +def test_json_mode_logs_one_ecs_object_per_request(capsys): + """JSON mode must emit a parseable ECS record with a duration to aggregate.""" + app = FastAPI() + app.add_middleware(AccessLogMiddleware, settings=Settings()) + + @app.get("/api/files/{name}") + async def files(name: str, request: Request): + request.state.fg_username = "alice" + request.state.fg_token_id = "a1b2c3d4e5f6" + logger.info("handling the request") + return {"ok": True} + + configure_logging("INFO", "json") + try: + with TestClient(app) as client: + response = client.get("/api/files/share?x=1") + finally: + configure_logging("INFO", "text") + + records = [json.loads(line) for line in capsys.readouterr().out.splitlines()] + access = [r for r in records if r.get("event.dataset") == "fileglancer.access"] + assert len(access) == 1, records + record = access[0] + + assert record["service.name"] == "fileglancer" + assert record["http.request.method"] == "GET" + assert record["http.response.status_code"] == 200 + assert record["url.path"] == "/api/files/share" + assert record["url.query"] == "x=1" + assert record["user.name"] == "alice" + assert record["labels.token_id"] == "a1b2c3d4e5f6" + assert record["labels.endpoint"] == "files" + # The whole point: a number Kibana can take percentiles of. + assert isinstance(record["event.duration"], int) and record["event.duration"] > 0 + + # The id is returned to the client and shared with every line logged while + # serving the request, so both can be found from a single bug report. + assert response.headers["x-request-id"] == record["trace.id"] + handler_line = [r for r in records if r["message"] == "handling the request"] + assert handler_line and handler_line[0]["trace.id"] == record["trace.id"] + + +def test_json_mode_records_exceptions(capsys): + """A logged exception must arrive as ECS error fields, not a wall of text.""" + configure_logging("INFO", "json") + try: + try: + raise ValueError("boom") + except ValueError: + logger.exception("it broke") + finally: + configure_logging("INFO", "text") + + record = json.loads(capsys.readouterr().out.strip()) + assert record["error.type"] == "ValueError" + assert record["error.message"] == "boom" + assert "ValueError: boom" in record["error.stack_trace"] + + +def test_text_mode_is_the_default_and_stays_human_readable(capsys): + """Default settings must log exactly as before, not JSON.""" + assert Settings().log_format == "text" + + app, lines, sink_id = _app_and_lines() + try: + with TestClient(app) as client: + client.get("/api/content/share/file.txt") + finally: + logger.remove(sink_id) + + assert '"GET /api/content/share/file.txt HTTP/1.1" 200 - ' in lines[0] + assert "{" not in lines[0], lines[0] diff --git a/tests/test_worker.py b/tests/test_worker.py index b0157c099..4e5bf4f91 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -95,6 +95,7 @@ def test_strips_all_fgc_variables(self): for key in list(env): assert not key.upper().startswith("FGC_") or key in ( "FGC_LOG_LEVEL", + "FGC_LOG_FORMAT", "FGC_WORKER_FD", ), f"secret leaked: {key}" # The specific secrets are gone regardless of case. @@ -143,6 +144,7 @@ def test_preserves_allowlisted_and_sets_operational_vars(self): # Operational vars the worker needs are set explicitly. assert env["HOME"] == "/home/alice" assert env["FGC_LOG_LEVEL"] == "DEBUG" + assert env["FGC_LOG_FORMAT"] == "text" assert env["FGC_WORKER_FD"] == "9" def test_passthrough_allows_site_specific_names_and_prefixes(self): From 65d48a1e36c229e85c4119ae1c5400591c02831b Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 28 Aug 2026 14:03:59 -0400 Subject: [PATCH 42/43] chore: drop Windows support Fileglancer is a POSIX server: the worker pool uses fork/setuid/SCM_RIGHTS, the job poll loop uses fcntl locking, and file permissions come from pwd/grp. It cannot run on native Windows, so the Windows CI leg only ever proved that the code imported there. Windows users run under WSL2, which is linux-64. Removes the windows-latest matrix leg, the win-64 pixi platform, the try/except ImportError shims around pwd/grp/fcntl, the Windows drive-letter path pattern, the ntpath job-path helpers, and 14 win32 test skips. Keeps client.py's backslash handling: FileSharePath.windows_path is a real UNC field users paste from a Windows desktop, independent of the client's OS. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/build.yml | 16 +- fileglancer/apps/command.py | 46 +- fileglancer/apps/jobfiles.py | 37 +- fileglancer/apps/jobs.py | 15 +- fileglancer/filestore.py | 8 +- fileglancer/server.py | 8 +- fileglancer/sshkeys.py | 5 +- fileglancer/user_worker.py | 5 +- fileglancer/worker_pool.py | 5 +- pixi.lock | 2858 ---------------------------------- pyproject.toml | 2 +- tests/test_apps.py | 34 - tests/test_client_paths.py | 3 +- tests/test_filestore.py | 5 - tests/test_worker.py | 6 - 15 files changed, 36 insertions(+), 3017 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 35f913b68..1730dae22 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,13 +13,8 @@ concurrency: jobs: build: - name: Build (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, windows-latest] + name: Build + runs-on: ubuntu-latest env: VITE_ENABLE_TASKS: true @@ -37,43 +32,36 @@ jobs: manifest-path: pyproject.toml - name: Install dependencies - shell: bash run: | set -eux pixi run dev-install - name: Lint - if: matrix.os == 'ubuntu-latest' run: | set -eux pixi run node-prettier-check && pixi run node-eslint-check - name: Test the frontend - shell: bash run: | set -eux pixi run test-frontend - name: Test the backend - shell: bash run: | set -eux pixi run test-backend - name: Build the frontend - shell: bash run: | set -eux pixi run node-build - name: Package the app - if: matrix.os == 'ubuntu-latest' run: | set -eux pixi run pypi-build - name: Upload package to GH - if: matrix.os == 'ubuntu-latest' uses: actions/upload-artifact@v4 with: name: extension-artifacts diff --git a/fileglancer/apps/command.py b/fileglancer/apps/command.py index 86bd4294c..f735cf4b9 100644 --- a/fileglancer/apps/command.py +++ b/fileglancer/apps/command.py @@ -4,10 +4,7 @@ import re import shlex -try: - import pwd -except ImportError: - pwd = None # type: ignore[assignment] +import pwd from fileglancer import database as db from fileglancer.model import ( @@ -199,7 +196,6 @@ def build_requirements_check(requirements: list[str]) -> str: # Characters that are dangerous in shell commands _SHELL_METACHAR_PATTERN = re.compile(r'[;&|`$(){}!<>\n\r]') -_WINDOWS_DRIVE_PATTERN = re.compile(r'^[a-zA-Z]:/') # Cloud storage URI schemes that are passed through as opaque strings rather # than treated as local filesystem paths. @@ -209,37 +205,34 @@ def build_requirements_check(requirements: list[str]) -> str: def expand_user_path(path_value: str, username: str | None = None) -> str: """Normalize a file/directory parameter value. - Replaces backslashes with '/', passes cloud-storage URIs through unchanged, - and expands a leading '~' / '~/' to the target user's home directory. + Passes cloud-storage URIs through unchanged and expands a leading + '~' / '~/' to the target user's home directory. The home is resolved from *username* via pwd.getpwnam so this works from the root server process, where os.geteuid() would wrongly resolve to /root. Falls back to the effective uid's home, then os.path.expanduser, when username is None (CLI / tests) or the lookup fails. """ - normalized = path_value.replace("\\", "/") + if path_value.startswith(_URI_PREFIXES): + return path_value - if normalized.startswith(_URI_PREFIXES): - return normalized - - if normalized == "~" or normalized.startswith("~/"): + if path_value == "~" or path_value.startswith("~/"): home = None - if username and pwd is not None: + if username: try: home = pwd.getpwnam(username).pw_dir except KeyError: home = None - if home is None and pwd is not None: + if home is None: try: home = pwd.getpwuid(os.geteuid()).pw_dir - except (AttributeError, KeyError): + except KeyError: home = None if home is None: home = os.path.expanduser("~") - home = home.replace("\\", "/") - return home + normalized[1:] + return home + path_value[1:] - return normalized + return path_value def validate_path_for_shell(path_value: str) -> str | None: @@ -249,20 +242,17 @@ def validate_path_for_shell(path_value: str) -> str | None: start with '/', '~', or './'. Returns an error message string if invalid, or None if valid. """ - normalized = path_value.replace("\\", "/") - # Cloud storage URIs are passed through as opaque strings - if normalized.startswith(_URI_PREFIXES): + if path_value.startswith(_URI_PREFIXES): return None - if _SHELL_METACHAR_PATTERN.search(normalized): + if _SHELL_METACHAR_PATTERN.search(path_value): return "Path contains invalid characters" - if ".." in normalized: + if ".." in path_value: return "Path must not contain '..'" - if not (normalized.startswith("/") or normalized.startswith("~") or normalized.startswith("./") - or _WINDOWS_DRIVE_PATTERN.match(normalized)): + if not path_value.startswith(("/", "~", "./")): return "Must be an absolute or relative path (starting with /, ~, or ./)" return None @@ -296,14 +286,12 @@ def validate_path_in_filestore(path_value: str, fsps: list, check_access: bool = if error: return error - normalized = path_value.replace("\\", "/") - # Relative paths and cloud storage URIs are not local filesystem paths; # skip filestore validation. - if normalized.startswith("./") or normalized.startswith(_URI_PREFIXES): + if path_value.startswith("./") or path_value.startswith(_URI_PREFIXES): return None - expanded = os.path.expanduser(normalized) + expanded = os.path.expanduser(path_value) # Resolve to a file share path (euid-independent containment check) from fileglancer.database import find_fsp_in_paths diff --git a/fileglancer/apps/jobfiles.py b/fileglancer/apps/jobfiles.py index b2fb96139..7f0edf9a3 100644 --- a/fileglancer/apps/jobfiles.py +++ b/fileglancer/apps/jobfiles.py @@ -1,6 +1,5 @@ """Job working-directory path construction and job file access.""" -import ntpath import os import posixpath import re @@ -103,30 +102,6 @@ def delete_job_work_dir(db_job: db.JobDB) -> bool: return False -def _stored_work_dir_path(db_job: db.JobDB) -> str: - """Return the job's work_dir as a stored path string. - - Job records often contain POSIX cluster paths even when the API process is - inspected or tested on Windows. Keep stored paths as strings for metadata - responses so pathlib does not rewrite separators to the local OS style. - """ - if db_job.work_dir: - return str(db_job.work_dir) - return str(_build_work_dir(db_job.id, db_job.app_name, db_job.entry_point_id)) - - -def _join_stored_path(directory: str, filename: str) -> str: - """Join a filename to a stored job path without OS-specific normalization.""" - if "\\" in directory and "/" not in directory: - return ntpath.join(directory, filename) - return posixpath.join(directory, filename) - - -def _stored_path_basename(file_path: str) -> str: - """Return the final path component for either POSIX or Windows separators.""" - return file_path.rstrip("/\\").replace("\\", "/").rsplit("/", 1)[-1] - - def _make_file_info(file_path: str, exists: bool, work_fsp_name: Optional[str], work_subpath: Optional[str]) -> dict: @@ -139,7 +114,7 @@ def _make_file_info(file_path: str, exists: bool, subpath = None if exists and work_fsp_name: fsp_name = work_fsp_name - filename = _stored_path_basename(file_path) + filename = posixpath.basename(file_path) subpath = f"{work_subpath}/{filename}" if work_subpath else filename return { "path": file_path, @@ -228,7 +203,7 @@ def get_job_file_paths(db_job: db.JobDB) -> dict[str, dict]: rather than stat'd: the script exists once submitted (script_path is set), and the log files exist once the job has started. """ - work_dir = _stored_work_dir_path(db_job) + work_dir = str(_resolve_work_dir(db_job)) work_fsp_name = getattr(db_job, 'work_dir_fsp_name', None) work_subpath = getattr(db_job, 'work_dir_subpath', None) @@ -236,12 +211,12 @@ def get_job_file_paths(db_job: db.JobDB) -> dict[str, dict]: script_path = getattr(db_job, 'script_path', None) script_exists = bool(script_path) if not script_path: - script_path = _join_stored_path(work_dir, "script.sh") + script_path = posixpath.join(work_dir, "script.sh") # Log files are written by the job once it begins running. logs_exist = db_job.started_at is not None - stdout_path = _join_stored_path(work_dir, "stdout.log") - stderr_path = _join_stored_path(work_dir, "stderr.log") + stdout_path = posixpath.join(work_dir, "stdout.log") + stderr_path = posixpath.join(work_dir, "stderr.log") files = { # The work dir itself: its browse-link base is the stored fsp/subpath, @@ -260,7 +235,7 @@ def get_job_file_paths(db_job: db.JobDB) -> dict[str, dict]: # Include service_url file info for running service-type jobs. if getattr(db_job, 'entry_point_type', 'job') == 'service': - service_url_path = _join_stored_path(work_dir, "service_url") + service_url_path = posixpath.join(work_dir, "service_url") files["service_url"] = _make_file_info( service_url_path, db_job.status == 'RUNNING', work_fsp_name, work_subpath) diff --git a/fileglancer/apps/jobs.py b/fileglancer/apps/jobs.py index 74be309b5..c5603438a 100644 --- a/fileglancer/apps/jobs.py +++ b/fileglancer/apps/jobs.py @@ -1,10 +1,7 @@ """Cluster job lifecycle: background status polling, submission, and cancellation.""" import asyncio -try: - import fcntl -except ImportError: - fcntl = None # type: ignore[assignment] +import fcntl import os import re import shlex @@ -34,7 +31,6 @@ merge_requirements, _ENV_VAR_NAME_PATTERN, _URI_PREFIXES, - _WINDOWS_DRIVE_PATTERN, ) from fileglancer.apps.jobfiles import _build_work_dir from fileglancer.giturls import canonical_github_url, same_github_repo @@ -619,18 +615,11 @@ def _container_bind_paths(entry_point, parameters: dict, for param, raw_value in collect_path_parameters( entry_point, parameters, env_parameters): expanded = expand_user_path(str(raw_value), username) - # Windows drive paths count as absolute so a dev/test server on Windows - # composes the same script; path validation accepts them the same way. - is_absolute = (expanded.startswith("/") - or _WINDOWS_DRIVE_PATTERN.match(expanded)) - if expanded.startswith(_URI_PREFIXES) or not is_absolute: + if expanded.startswith(_URI_PREFIXES) or not expanded.startswith("/"): continue if param.type == "directory": bind_paths.append(expanded) else: - # PurePosixPath: the bind flag lands in a bash script, and the value - # is already '/'-normalized, so the parent must stay POSIX-style - # even when the server process runs on Windows (dev/test). bind_paths.append(str(PurePosixPath(expanded).parent)) if entry_point.bind_paths: bind_paths.extend(entry_point.bind_paths) diff --git a/fileglancer/filestore.py b/fileglancer/filestore.py index 59fac421f..eca3f1ef3 100644 --- a/fileglancer/filestore.py +++ b/fileglancer/filestore.py @@ -6,12 +6,8 @@ import itertools import os import stat -try: - import pwd - import grp -except ImportError: - pwd = None # type: ignore[assignment] - grp = None # type: ignore[assignment] +import pwd +import grp import shutil from pydantic import BaseModel diff --git a/fileglancer/server.py b/fileglancer/server.py index d4f3bf460..62c6b2bee 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -3,12 +3,8 @@ import re import shlex import sys -try: - import pwd - import grp -except ImportError: - pwd = None # type: ignore[assignment] - grp = None # type: ignore[assignment] +import pwd +import grp import json import secrets from datetime import datetime, timedelta, timezone, UTC diff --git a/fileglancer/sshkeys.py b/fileglancer/sshkeys.py index a20084eed..a2b561d92 100644 --- a/fileglancer/sshkeys.py +++ b/fileglancer/sshkeys.py @@ -6,10 +6,7 @@ import gc import os -try: - import pwd -except ImportError: - pwd = None # type: ignore[assignment] +import pwd import shutil import subprocess import tempfile diff --git a/fileglancer/user_worker.py b/fileglancer/user_worker.py index 58164148b..b37eb8d62 100644 --- a/fileglancer/user_worker.py +++ b/fileglancer/user_worker.py @@ -29,10 +29,7 @@ import json import logging import os -try: - import pwd -except ImportError: - pwd = None # type: ignore[assignment] +import pwd import socket import struct import sys diff --git a/fileglancer/worker_pool.py b/fileglancer/worker_pool.py index 561c4a16c..56057bb55 100644 --- a/fileglancer/worker_pool.py +++ b/fileglancer/worker_pool.py @@ -27,10 +27,7 @@ import asyncio import json import os -try: - import pwd -except ImportError: - pwd = None # type: ignore[assignment] +import pwd import socket import struct import subprocess diff --git a/pixi.lock b/pixi.lock index 3f354474b..84132dda3 100644 --- a/pixi.lock +++ b/pixi.lock @@ -4,7 +4,6 @@ platforms: - name: linux-aarch64 - name: osx-64 - name: osx-arm64 -- name: win-64 environments: default: channels: @@ -673,167 +672,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.18.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/atlassian-python-api-4.0.7-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/authlib-1.7.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cachetools-6.2.6-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/deprecated-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ecdsa-0.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.136.3-h5ddb490_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.136.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.1.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/joserfc-1.7.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh7428d3b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.12-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/oauthlib-3.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/psycopg2-binary-2.9.10-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyasn1-0.6.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-extra-types-2.11.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyjwt-2.13.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-jose-3.5.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2026.3.post1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-kerberos-0.14.0-pyh7428d3b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-oauthlib-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.20.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rsa-4.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-1.3.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.27.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win32_setctime-1.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-48.0.1-py314he884d78_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/fastar-0.11.0-py314h401f7ea_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmpy2-2.3.0-py314h9f8d836_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.8.0-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.13.0-default_h049141e_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libpq-18.4-h6b13b41_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.1-py314hcdb55d9_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpc-1.4.0-hc817d3a_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpfr-4.2.2-h883a981_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-22.23.1-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.1-py314h02f10f6_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.1.0-h57928b3_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.10-py314h3cd40a6_4.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py314h9f07db2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyspnego-0.12.1-py314h86ab7b2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py314hc5dbbe4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sspilib-0.5.0-py314h5a2d7ad_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.2.0-py314hc980628_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/websockets-16.1.1-py314h13f4da2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.2.2-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - - pypi: . - - pypi: https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl release: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -1687,212 +1525,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.18.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/atlassian-python-api-4.0.7-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/authlib-1.7.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_5.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.2.0-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cachetools-6.2.6-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/deprecated-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/distro-1.9.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.23-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ecdsa-0.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.6-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.136.3-h5ddb490_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.136.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.32.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hatch-1.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.31.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperlink-21.0.0-pyh29332c3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/id-1.6.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhcf101f3_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-6.1.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.6.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.1.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/joserfc-1.7.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh7428d3b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh7428d3b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.12-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-11.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/oauthlib-3.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/psycopg2-binary-2.9.10-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyasn1-0.6.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-extra-types-2.11.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyjwt-2.13.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyproject_hooks-1.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.5.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-jose-3.5.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2026.3.post1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-45.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-kerberos-0.14.0-pyh7428d3b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-oauthlib-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-1.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.20.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rsa-4.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-1.3.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2026.6.1.19-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/twine-6.2.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.27.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/userpath-1.9.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.7.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win32_setctime-1.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/comrak-0.54.0-h18a1a76_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-48.0.1-py314he884d78_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/fastar-0.11.0-py314h401f7ea_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmpy2-2.3.0-py314h9f8d836_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.8.0-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.13.0-default_h049141e_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libpq-18.4-h6b13b41_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.1-py314hcdb55d9_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpc-1.4.0-hc817d3a_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpfr-4.2.2-h883a981_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/nh3-0.3.6-py310ha413424_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-22.23.1-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.1-py314h02f10f6_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.1.0-h57928b3_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.10-py314h3cd40a6_4.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py314h9f07db2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyspnego-0.12.1-py314h86ab7b2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py314h86ab7b2_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py314hc5dbbe4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sspilib-0.5.0-py314h5a2d7ad_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/uv-0.11.32-h7ca4a90_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.2.0-py314hc980628_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/websockets-16.1.1-py314h13f4da2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.2.2-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - - pypi: . - - pypi: https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl test: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -2913,255 +2545,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.18.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/atlassian-python-api-4.0.7-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/authlib-1.7.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.6.0-py314h680f03e_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cachetools-6.2.6-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.6-py314hd8ed1ab_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/deprecated-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ecdsa-0.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.136.3-h5ddb490_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.136.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.1.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/joserfc-1.7.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh7428d3b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.12-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/oauthlib-3.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh145f28c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/psycopg2-binary-2.9.10-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyasn1-0.6.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-extra-types-2.11.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyjwt-2.13.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-jupyter-0.10.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-jupyter-client-0.10.1-hd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-jupyter-server-0.10.1-hd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.6-h4df99d1_101.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-jose-3.5.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2026.3.post1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-kerberos-0.14.0-pyh7428d3b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-mock-1.12.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-oauthlib-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.20.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rsa-4.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-1.3.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.27.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win32_setctime-1.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py314h5a2d7ad_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/coverage-7.15.2-py314h2359020_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-48.0.1-py314he884d78_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/fastar-0.11.0-py314h401f7ea_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmpy2-2.3.0-py314h9f8d836_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py314hb98de8c_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.8.0-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.13.0-default_h049141e_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libpq-18.4-h6b13b41_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.1-py314hcdb55d9_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpc-1.4.0-hc817d3a_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpfr-4.2.2-h883a981_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-22.23.1-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.1-py314h02f10f6_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.1.0-h57928b3_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.10-py314h3cd40a6_4.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py314h9f07db2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyspnego-0.12.1-py314h86ab7b2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py314hcaaf0b2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py314h51f0985_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py314hc980628_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py314hc5dbbe4_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py314hc5dbbe4_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sspilib-0.5.0-py314h5a2d7ad_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.2.0-py314hc980628_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/websockets-16.1.1-py314h13f4da2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.2.2-py314h5a2d7ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - - pypi: . - - pypi: https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl test-py312: channels: - url: https://conda.anaconda.org/conda-forge/ @@ -4188,256 +3571,6 @@ environments: - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl - win-64: - - conda: https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/alembic-1.18.5-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-doc-0.0.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/annotated-types-0.8.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/argon2-cffi-25.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/arrow-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/atlassian-python-api-4.0.7-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/authlib-1.7.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/backports.asyncio.runner-1.2.0-pyh5ded981_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.15.0-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-6.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/bleach-with-css-6.4.0-hac0b51c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/blinker-1.9.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached-property-1.5.2-hd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cached_property-1.5.2-pyha770c72_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cachetools-6.2.6-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2026.7.22-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.9-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/comm-0.2.3-pyhe01879c_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/decorator-5.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/defusedxml-0.7.1-pyhd8ed1ab_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/deprecated-1.3.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/dnspython-2.8.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ecdsa-0.19.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email-validator-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/email_validator-2.3.0-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/executing-2.2.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-0.136.3-h5ddb490_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-cli-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fastapi-core-0.136.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/fqdn-1.5.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore2-2.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/httpx2-2.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.18-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/isoduration-20.11.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/itsdangerous-2.2.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jedi-0.20.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jmespath-1.1.0-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/joserfc-1.7.4-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonpointer-3.1.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-4.26.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-specifications-2025.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jsonschema-with-format-nongpl-4.26.0-hcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_client-8.9.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_events-0.12.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server-2.20.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_server_terminals-0.5.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/jupyterlab_pygments-0.3.0-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/lark-1.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh7428d3b_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.12-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-4.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/matplotlib-inline-0.2.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/mistune-3.3.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbclient-0.11.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbconvert-core-7.17.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nbformat-5.10.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/nest-asyncio2-1.7.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/oauthlib-3.3.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/overrides-7.7.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pandocfilters-1.5.0-pyhd8ed1ab_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/parso-0.8.7-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.3-pyh8b19718_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.11.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prometheus_client-0.26.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/prompt-toolkit-3.0.52-pyha770c72_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/psycopg2-binary-2.9.10-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pure_eval-0.2.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyasn1-0.6.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-2.13.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-extra-types-2.11.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pydantic-settings-2.14.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pyjwt-2.13.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.4.2-pyhcf101f3_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-asyncio-1.4.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-jupyter-0.10.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-jupyter-client-0.10.1-hd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-jupyter-server-0.10.1-hd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-dotenv-1.2.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-fastjsonschema-2.22.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-jose-3.5.0-pyhff2d567_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-json-logger-4.1.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-multipart-0.0.32-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python-tzdata-2026.3-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2026.3.post1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/referencing-0.37.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-kerberos-0.14.0-pyh7428d3b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-mock-1.12.1-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/requests-oauthlib-2.0.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3339-validator-0.1.4-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-validator-0.1.1-pyh9f0ad1d_0.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3987-syntax-1.1.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-15.0.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rich-toolkit-0.20.3-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/rsa-4.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/ruamel.yaml-0.19.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-83.0.0-pyh332efcf_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.9.1-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/stack_data-0.6.3-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/starlette-1.3.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tinycss2-1.4.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/traitlets-5.15.1-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/truststore-0.10.4-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typer-0.27.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-extensions-4.16.0-h69aa097_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing-inspection-0.4.2-pyhcf101f3_2.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.16.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/typing_utils-0.1.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uri-template-1.3.0-pyhd8ed1ab_1.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/wcwidth-0.8.2-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/webcolors-25.10.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win32_setctime-1.2.0-pyhd8ed1ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py312he06e257_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py312h06d0912_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312hc6d9e41_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py312he06e257_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/coverage-7.15.2-py312h05f76fc_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-48.0.1-py312h232196e_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py312ha1a9051_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/fastar-0.11.0-py312hb482819_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/gmpy2-2.3.0-py312he85694f_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py312ha1a9051_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.8.0-py312he06e257_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_20.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.13.0-default_h049141e_1000.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libpq-18.4-h6b13b41_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.1-py312h2f35c63_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py312h05f76fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpc-1.4.0-hc817d3a_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/mpfr-4.2.2-h883a981_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-22.23.1-h80d1838_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.1-py312ha3f287d_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.1.0-h57928b3_233.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py312hc128f0a_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py312he5662c2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.10-py312h2f859cb_4.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py312hdabe01f_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyspnego-0.12.1-py312h2e8e312_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.13-h0159041_0_cpython.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py312h829343e_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py312h275cf98_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py312h05f76fc_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py312hd944d65_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py312he5662c2_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py312he5662c2_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/sspilib-0.5.0-py312he06e257_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py312he06e257_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.2.0-py312hd944d65_1.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/websockets-16.1.1-py312h5247d40_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - - conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.2.2-py312he06e257_0.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - - pypi: . - - pypi: https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/10/a1/510b0a7fadc6f43a6ce50152e69dbd86415240835868bb0bd9b5b88b1e06/aioitertools-0.13.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/3e/43/7e7b2ec865caa92f67b8f0e9231a798d102724ca4c0e1f414316be1c1ef2/pytest_metadata-3.1.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/48/a2/87a0b61a3078be07ab04ec574ef6c683c764590ed0d2a50d00cbb23aeae7/pydantic_settings_yaml-0.2.0-py2.py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/6d/f4/5a7d76dc844d3ff8ed1f1a043158aa393794aebb787d3e2f8c0fe87f674f/aiobotocore-3.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl - - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/f4/72/d08f606f8b99e9dd16d45e01da49e7e4f8109a5484dc1b6ab6f88d309aef/py_cluster_api-0.8.0-py3-none-any.whl - - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl packages: - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda build_number: 20 @@ -8227,15 +7360,6 @@ packages: - pkg:pypi/blinker?source=hash-mapping size: 13934 timestamp: 1731096548765 -- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-h4c7d964_0.conda - sha256: 95e8e74062a5fe5f870ac8c90302b6e89945165fdaed7810606e84ddee6aac12 - md5: e27d2ac27b096dc51fedfcf775a53f9b - depends: - - __win - license: ISC - purls: [] - size: 132136 - timestamp: 1784754918886 - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.7.22-hbd8a1cb_0.conda sha256: 0a0544cf95f64394fe4959286f5c71f5444ad58feb0602e53becb27448d24da6 md5: 0f51e2391ade309db462a55611263e9c @@ -8300,20 +7424,6 @@ packages: - pkg:pypi/charset-normalizer?source=compressed-mapping size: 61418 timestamp: 1783505332569 -- conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyh6dadd2b_0.conda - sha256: 5b5c96afdd801dd9c3b78ebc2cd9a9f3ce34186257415d394dde1aa8468aa3c0 - md5: 8a0d65027e25e367f9f1754f0604e8de - depends: - - __win - - colorama - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/click?source=compressed-mapping - size: 106227 - timestamp: 1783085395110 - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.4.2-pyhc90fa1f_0.conda sha256: ccc4787f511964f9a1f2d2d2859c91c5d571fb60f7f09d4c4e092c9b7a94e671 md5: 2c4bd6aeb90bb157456841c3270a0d92 @@ -8892,33 +8002,6 @@ packages: - pkg:pypi/ipykernel?source=hash-mapping size: 137725 timestamp: 1781101860049 -- conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyh6dadd2b_0.conda - sha256: e3ff0b3d5db5c31830030406f50ac2c9a5c31b86f1c2cef87a6042f0a4c77eb7 - md5: dd5c51d5c42381ba4a2e0ce32e02ba17 - depends: - - __win - - comm >=0.1.1 - - debugpy >=1.6.5 - - ipython >=7.23.1 - - jupyter_client >=8.9.0 - - jupyter_core >=5.1,!=6.0.* - - matplotlib-inline >=0.1 - - nest-asyncio2 >=1.7.0 - - packaging >=22 - - psutil >=5.7 - - python >=3.10 - - pyzmq >=25 - - tornado >=6.4.1 - - traitlets >=5.4.0 - - python - constrains: - - appnope >=0.1.2 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/ipykernel?source=compressed-mapping - size: 138046 - timestamp: 1781101760172 - conda: https://conda.anaconda.org/conda-forge/noarch/ipykernel-7.3.0-pyha191276_0.conda sha256: 305ad9226363ff5f259c404dd9a7508183a2e150739b2adc43db7d817234da66 md5: 2b47a10e4d98334f8171ff60aea05ff3 @@ -8970,30 +8053,6 @@ packages: - pkg:pypi/ipython?source=compressed-mapping size: 658801 timestamp: 1782482716877 -- conda: https://conda.anaconda.org/conda-forge/noarch/ipython-9.15.0-pyhe2676ad_0.conda - sha256: c3df12e766572060be653933aae5f6a09ccebfa86cff3b10b9b880ace29088d9 - md5: a4c278221ad4da75ba1637f8d230e658 - depends: - - __win - - decorator >=5.1.0 - - ipython_pygments_lexers >=1.0.0 - - jedi >=0.18.2 - - matplotlib-inline >=0.1.6 - - prompt-toolkit >=3.0.41,<3.1.0 - - psutil >=7 - - pygments >=2.14.0 - - python >=3.11 - - stack_data >=0.6.0 - - traitlets >=5.13.0 - - typing_extensions >=4.6 - - colorama >=0.4.4 - - python - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/ipython?source=hash-mapping - size: 657739 - timestamp: 1782482745122 - conda: https://conda.anaconda.org/conda-forge/noarch/ipython_pygments_lexers-1.1.1-pyhd8ed1ab_0.conda sha256: 894682a42a7d659ae12878dbcb274516a7031bbea9104e92f8e88c1f2765a104 md5: bd80ba060603cc228d9d81c257093119 @@ -9206,24 +8265,6 @@ packages: - pkg:pypi/jupyter-client?source=hash-mapping size: 117954 timestamp: 1781019994076 -- conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyh6dadd2b_0.conda - sha256: ed709a6c25b731e01563521ef338b93986cd14b5bc17f35e9382000864872ccc - md5: a8db462b01221e9f5135be466faeb3e0 - depends: - - __win - - pywin32 - - platformdirs >=2.5 - - python >=3.10 - - traitlets >=5.3 - - python - constrains: - - pywin32 >=300 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/jupyter-core?source=hash-mapping - size: 64679 - timestamp: 1760643889625 - conda: https://conda.anaconda.org/conda-forge/noarch/jupyter_core-5.9.1-pyhc90fa1f_0.conda sha256: 1d34b80e5bfcd5323f104dbf99a2aafc0e5d823019d626d0dce5d3d356a2a52a md5: b38fe4e78ee75def7e599843ef4c1ab0 @@ -9336,24 +8377,6 @@ packages: - pkg:pypi/keyring?source=hash-mapping size: 37924 timestamp: 1763320995459 -- conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyh7428d3b_0.conda - sha256: ed76a29fd1dbaf1bb24058191386618315ab9e35da9ef9a76da232cd6885165b - md5: e91b0f2040c580527ccc54665aa7cdba - depends: - - __win - - importlib-metadata >=4.11.4 - - importlib_resources - - jaraco.classes - - jaraco.context - - jaraco.functools - - python >=3.10 - - pywin32-ctypes >=0.2.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/keyring?source=hash-mapping - size: 38153 - timestamp: 1763320939579 - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.7.0-pyha804496_0.conda sha256: 010718b1b1a35ce72782d38e6d6b9495d8d7d0dbea9a3e42901d030ff2189545 md5: 9eeb0eaf04fa934808d3e070eebbe630 @@ -9396,20 +8419,6 @@ packages: - pkg:pypi/loguru?source=hash-mapping size: 59696 timestamp: 1746634858826 -- conda: https://conda.anaconda.org/conda-forge/noarch/loguru-0.7.3-pyh7428d3b_0.conda - sha256: 647ce9601c1a63af4710a2d718a74fa521da28262c41bbf86189f6c0906b23f6 - md5: a6c25c54d8d524735db2e5785aec0a4e - depends: - - __win - - colorama >=0.3.4 - - python >=3.9 - - win32_setctime >=1.0.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/loguru?source=hash-mapping - size: 60119 - timestamp: 1746634872414 - conda: https://conda.anaconda.org/conda-forge/noarch/mako-1.3.12-pyhcf101f3_0.conda sha256: d06d02574be3892020262464b49360a749c1d448ed9f0de52fe8a08bc1483261 md5: a73036dabdd6dfe9679ed893baa8b230 @@ -9859,19 +8868,6 @@ packages: - pkg:pypi/pyproject-hooks?source=hash-mapping size: 15528 timestamp: 1733710122949 -- conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyh09c184e_7.conda - sha256: d016e04b0e12063fbee4a2d5fbb9b39a8d191b5a0042f0b8459188aedeabb0ca - md5: e2fd202833c4a981ce8a65974fe4abd1 - depends: - - __win - - python >=3.9 - - win_inet_pton - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/pysocks?source=hash-mapping - size: 21784 - timestamp: 1733217448189 - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda sha256: ba3b032fa52709ce0d9fd388f63d330a026754587a2f461117cac9ab73d8d0d8 md5: 461219d1a5bd61342293efa2c0c90eac @@ -10194,21 +9190,6 @@ packages: - pkg:pypi/requests-kerberos?source=hash-mapping size: 16850 timestamp: 1708339727339 -- conda: https://conda.anaconda.org/conda-forge/noarch/requests-kerberos-0.14.0-pyh7428d3b_1.conda - sha256: c6a2824cefd3bbe8596a62a257d2e5a29453ffe481f62ec4f173c1c37c3185eb - md5: bd9d33d8ddd36fe2765a3a56120df638 - depends: - - __win - - cryptography >=1.3 - - pyspnego - - python >=3.6 - - requests >=1.1.0 - license: ISC - license_family: MIT - purls: - - pkg:pypi/requests-kerberos?source=hash-mapping - size: 17221 - timestamp: 1708339875538 - conda: https://conda.anaconda.org/conda-forge/noarch/requests-kerberos-0.14.0-pyhd8ed1ab_1.conda sha256: 3f8308592ad58497de441633dda03e167e81d9c7105e87870ed14d06252ecf60 md5: 81e8dbf95810b81042957091aaaef9f6 @@ -10375,20 +9356,6 @@ packages: - pkg:pypi/send2trash?source=hash-mapping size: 22519 timestamp: 1770937603551 -- conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyh6dadd2b_1.conda - sha256: 305446a0b018f285351300463653d3d3457687270e20eda37417b12ee386ef76 - md5: 6ac53f3fff2c416d63511843a04646fa - depends: - - __win - - pywin32 - - python >=3.10 - - python - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/send2trash?source=hash-mapping - size: 22864 - timestamp: 1770937641143 - conda: https://conda.anaconda.org/conda-forge/noarch/send2trash-2.1.0-pyha191276_1.conda sha256: 59656f6b2db07229351dfb3a859c35e57cc8e8bcbc86d4e501bff881a6f771f1 md5: 28eb91468df04f655a57bcfbb35fc5c5 @@ -10486,21 +9453,6 @@ packages: - pkg:pypi/starlette?source=compressed-mapping size: 64264 timestamp: 1781447071524 -- conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyh6dadd2b_1.conda - sha256: b375e8df0d5710717c31e7c8e93c025c37fa3504aea325c7a55509f64e5d4340 - md5: e43ca10d61e55d0a8ec5d8c62474ec9e - depends: - - __win - - pywinpty >=1.1.0 - - python >=3.10 - - tornado >=6.1.0 - - python - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/terminado?source=hash-mapping - size: 23665 - timestamp: 1766513806974 - conda: https://conda.anaconda.org/conda-forge/noarch/terminado-0.18.1-pyhc90fa1f_1.conda sha256: 6b6727a13d1ca6a23de5e6686500d0669081a117736a87c8abf444d60c1e40eb md5: 17b43cee5cc84969529d5d0b0309b2cb @@ -10742,21 +9694,6 @@ packages: - pkg:pypi/uvicorn?source=hash-mapping size: 51717 timestamp: 1760803935306 -- conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-0.38.0-pyh5737063_0.conda - sha256: ebb120ec1626ced65f3965c08f9ac58d57a18488f991a87dad89f002a2094cb2 - md5: 8fb44dcece55529465f9e6f3e40eef61 - depends: - - __win - - click >=7.0 - - h11 >=0.8 - - python >=3.10 - - typing_extensions >=4.0 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/uvicorn?source=hash-mapping - size: 51772 - timestamp: 1760804061872 - conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h31011fe_0.conda sha256: 3629a349257c0e129cbb84fd593759a31d68ac1219c0af8b8ed89b95b9574c9b md5: 1ce870d7537376362672f5ff57109529 @@ -10774,23 +9711,6 @@ packages: purls: [] size: 7719 timestamp: 1760803936446 -- conda: https://conda.anaconda.org/conda-forge/noarch/uvicorn-standard-0.38.0-h5737063_0.conda - sha256: ba4a9d4962a671efd2b911c0be9f576beecff8cc606344a46e0c67720e9f5dbc - md5: 816b80d606a73c2ffaf55e84c3ff2516 - depends: - - __win - - colorama >=0.4 - - httptools >=0.6.3 - - python-dotenv >=0.13 - - pyyaml >=5.1 - - uvicorn 0.38.0 pyh5737063_0 - - watchfiles >=0.13 - - websockets >=10.4 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 8179 - timestamp: 1760804064891 - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.7.0-pyhcf101f3_0.conda sha256: 3fc8bad417e318268ecd1b64d9f3d7bdb6cefcae9eaed8433f488627f668234c md5: fc46c35c4925f7e0a43bb8c772c038bf @@ -10865,28 +9785,6 @@ packages: - pkg:pypi/wheel?source=hash-mapping size: 33491 timestamp: 1776878563806 -- conda: https://conda.anaconda.org/conda-forge/noarch/win32_setctime-1.2.0-pyhd8ed1ab_0.conda - sha256: d7b3128166949d462133d7a86fd8a8d80224dd2ce49cfbdcde9e4b3f8b67bbf2 - md5: e79f83003ee3dba79bf795fcd1bfcc89 - depends: - - python >=3.9 - license: MIT - license_family: MIT - purls: - - pkg:pypi/win32-setctime?source=hash-mapping - size: 9751 - timestamp: 1733752552137 -- conda: https://conda.anaconda.org/conda-forge/noarch/win_inet_pton-1.1.0-pyh7428d3b_8.conda - sha256: 93807369ab91f230cf9e6e2a237eaa812492fe00face5b38068735858fba954f - md5: 46e441ba871f524e2b067929da3051c2 - depends: - - __win - - python >=3.9 - license: LicenseRef-Public-Domain - purls: - - pkg:pypi/win-inet-pton?source=hash-mapping - size: 9555 - timestamp: 1733130678956 - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda sha256: 210bd31c22bb88f5e2a167df24c95bb5f152b2ada7502f9b8c49d1f5366db423 md5: ba3dcdc8584155c97c648ae9c044b7a3 @@ -14140,1672 +13038,6 @@ packages: purls: [] size: 433413 timestamp: 1764777166076 -- conda: https://conda.anaconda.org/conda-forge/win-64/_openmp_mutex-4.5-20_gnu.conda - build_number: 20 - sha256: 8a1cee28bd0ee7451ada1cd50b64720e57e17ff994fc62dd8329bef570d382e4 - md5: 1626967b574d1784b578b52eaeb071e7 - depends: - - libgomp >=7.5.0 - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - constrains: - - openmp_impl <0.0a0 - - msys2-conda-epoch <0.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 52252 - timestamp: 1770943776666 -- conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py312he06e257_2.conda - sha256: 38c5e43d991b0c43713fa2ceba3063afa4ccad2dd4c8eb720143de54d461a338 - md5: 5dc3781bbc4ddce0bf250a04c1a192c2 - depends: - - cffi >=1.0.1 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/argon2-cffi-bindings?source=hash-mapping - size: 38535 - timestamp: 1762509763237 -- conda: https://conda.anaconda.org/conda-forge/win-64/argon2-cffi-bindings-25.1.0-py314h5a2d7ad_2.conda - sha256: a742e7cd0d5534bfff3fd550a0c1e430411fad60a24f88930d261056ab08096f - md5: ffa247e46f47e157851dc547f4c513e4 - depends: - - cffi >=2.0.0b1 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/argon2-cffi-bindings?source=hash-mapping - size: 38653 - timestamp: 1762509771011 -- conda: https://conda.anaconda.org/conda-forge/win-64/backports.zstd-1.6.0-py312h06d0912_0.conda - sha256: 9926f274d8b642f5421e4536952cb158912517f40acf1df3a8fbd891c5f600ed - md5: 0d8bcdc0af72309fb998811f5f4db2c5 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - - zstd >=1.5.7,<1.6.0a0 - license: BSD-3-Clause AND MIT AND EPL-2.0 - purls: - - pkg:pypi/backports-zstd?source=compressed-mapping - size: 238542 - timestamp: 1781450836106 -- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py312hc6d9e41_1.conda - sha256: 2bb6f384a51929ef2d5d6039fcf6c294874f20aaab2f63ca768cbe462ed4b379 - md5: e8e7a6346a9e50d19b4daf41f367366f - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - libbrotlicommon 1.2.0 hfd05255_1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/brotli?source=hash-mapping - size: 335482 - timestamp: 1764018063640 -- conda: https://conda.anaconda.org/conda-forge/win-64/brotli-python-1.2.0-py314he701e3d_1.conda - sha256: 6854ee7675135c57c73a04849c29cbebc2fb6a3a3bfee1f308e64bf23074719b - md5: 1302b74b93c44791403cbeee6a0f62a3 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - libbrotlicommon 1.2.0 hfd05255_1 - license: MIT - license_family: MIT - purls: - - pkg:pypi/brotli?source=hash-mapping - size: 335782 - timestamp: 1764018443683 -- conda: https://conda.anaconda.org/conda-forge/win-64/bzip2-1.0.8-h0ad9c76_9.conda - sha256: 76dfb71df5e8d1c4eded2dbb5ba15bb8fb2e2b0fe42d94145d5eed4c75c35902 - md5: 4cb8e6b48f67de0b018719cdf1136306 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: bzip2-1.0.6 - license_family: BSD - purls: [] - size: 56115 - timestamp: 1771350256444 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py312he06e257_0.conda - sha256: de3d6315389ed26a62a4130a3022350e6d9a21b117128227ce0a83d158a56c39 - md5: 2421fbb9ae81fff1769994ede64f4ac4 - depends: - - pycparser - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/cffi?source=hash-mapping - size: 344466 - timestamp: 1783424278847 -- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.0-py314h5a2d7ad_0.conda - sha256: cc87e3ddaedd546dec4be978422058cd323cd2e973e1e503edf01da18fc5ecd4 - md5: fbc8f87f9d7bad413932c7e7d9e45eb5 - depends: - - pycparser - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/cffi?source=compressed-mapping - size: 348640 - timestamp: 1783424290792 -- conda: https://conda.anaconda.org/conda-forge/win-64/comrak-0.54.0-h18a1a76_0.conda - sha256: e70de9bf81c5bd25886c26efe6d7183a92a2dc289d4c29de43c988e0d66c8252 - md5: 59d3d746e7c8d7575e0098dcacd0855f - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 1213468 - timestamp: 1783844592240 -- conda: https://conda.anaconda.org/conda-forge/win-64/coverage-7.15.2-py312h05f76fc_0.conda - sha256: 5685583c45c43fafef253a65baf9e9d45863fbf5b9b8a970e2db0f39ad2c16e3 - md5: eb04595bd0c3634079ba51980657d75e - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - tomli - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/coverage?source=compressed-mapping - size: 420526 - timestamp: 1784150195164 -- conda: https://conda.anaconda.org/conda-forge/win-64/coverage-7.15.2-py314h2359020_0.conda - sha256: 1003bfdef9442d187f13479d8831ec6dd064726671fbc33a2f6adf53a357bb3d - md5: a2f60cd8a6b2f3af76d2e8b463c820c6 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - tomli - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: APACHE - purls: - - pkg:pypi/coverage?source=compressed-mapping - size: 443658 - timestamp: 1784150197776 -- conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-48.0.1-py312h232196e_0.conda - sha256: ae70058545691140f2f6833e648c94690ef1dabe57bfa235de1c597248fd721f - md5: 39e5d53c6966d56a64c39e1411226de9 - depends: - - cffi >=2.0 - - openssl >=3.5.6,<4.0a0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 AND BSD-3-Clause AND PSF-2.0 AND MIT - license_family: BSD - purls: - - pkg:pypi/cryptography?source=hash-mapping - size: 1645896 - timestamp: 1781061086891 -- conda: https://conda.anaconda.org/conda-forge/win-64/cryptography-48.0.1-py314he884d78_0.conda - sha256: fd7c121f0122ba9ffecb1e8af592876cf9262f6dbb9bcbfc31fd264a35a998aa - md5: a90ce37b0a267b07fb7f32f97582a716 - depends: - - cffi >=2.0 - - openssl >=3.5.6,<4.0a0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 AND BSD-3-Clause AND PSF-2.0 AND MIT - license_family: BSD - purls: - - pkg:pypi/cryptography?source=hash-mapping - size: 1654068 - timestamp: 1781061070555 -- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py312ha1a9051_0.conda - sha256: a41f403ca4b7b0c001140ac7a39fce1c0494ba95e8359f7a47590ed4377728a2 - md5: 5628287239c9ef6df2d66dc143f7c8dd - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/debugpy?source=hash-mapping - size: 3995148 - timestamp: 1780390185301 -- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.21-py314hb98de8c_0.conda - sha256: daa9397ffba6722f27c21b2f0a02b197f3ba9baedb484a155539743ec5ea3ac3 - md5: 945786ac874b8e821a675fbdd885f844 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/debugpy?source=hash-mapping - size: 4022782 - timestamp: 1780390190830 -- conda: https://conda.anaconda.org/conda-forge/win-64/fastar-0.11.0-py312hb482819_0.conda - sha256: e3313f29adf05bb0482a181d4be7670f4ca08bd9d899685e572fdd91a9b2f392 - md5: 6959c616e2d197a149e48ef45f133e9e - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - - zstd >=1.5.7,<1.6.0a0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/fastar?source=hash-mapping - size: 288433 - timestamp: 1776177912149 -- conda: https://conda.anaconda.org/conda-forge/win-64/fastar-0.11.0-py314h401f7ea_0.conda - sha256: 87ee580fa445715448ba48526c5b74458f8558d3636c6735f3104728ff5aa89c - md5: fa714ab1647d53647c7eb71970a764b0 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - zstd >=1.5.7,<1.6.0a0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/fastar?source=hash-mapping - size: 287934 - timestamp: 1776177910241 -- conda: https://conda.anaconda.org/conda-forge/win-64/gmp-6.3.0-hfeafd45_2.conda - sha256: 664311ea9164898f72d43b7ebf591b1a337d574cbb0d5026b4a8f21000dc8b29 - md5: 74558de25a206a7dff062fd4f5ff2d8b - depends: - - libgcc >=13 - - libwinpthread >=12.0.0.r2.ggc561118da - - ucrt >=10.0.20348.0 - constrains: - - mpir <0.0a0 - license: GPL-2.0-or-later OR LGPL-3.0-or-later - purls: [] - size: 567053 - timestamp: 1718982076982 -- conda: https://conda.anaconda.org/conda-forge/win-64/gmpy2-2.3.0-py312he85694f_1.conda - sha256: 674061d4220236a7eccd0f0674ac07edc9ec51c27612080909514ef0d9633042 - md5: 81fc16807d983e753193589086266b9a - depends: - - gmp >=6.3.0,<7.0a0 - - mpc >=1.3.1,<2.0a0 - - mpfr >=4.2.1,<5.0a0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LGPL-3.0-or-later - license_family: LGPL - purls: - - pkg:pypi/gmpy2?source=hash-mapping - size: 189710 - timestamp: 1773245181625 -- conda: https://conda.anaconda.org/conda-forge/win-64/gmpy2-2.3.0-py314h9f8d836_1.conda - sha256: 91c23e30f3919127abe6feb594caa28945cf01d371ac7109cfc800c7a0faef04 - md5: 0a12acb60f25b68377913d880b7602c4 - depends: - - gmp >=6.3.0,<7.0a0 - - mpc >=1.3.1,<2.0a0 - - mpfr >=4.2.1,<5.0a0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LGPL-3.0-or-later - license_family: LGPL - purls: - - pkg:pypi/gmpy2?source=hash-mapping - size: 188607 - timestamp: 1773245185271 -- conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py312ha1a9051_0.conda - sha256: 57f2bdf925909669797173c4c87588adf2e83abcd9f1d5d29a7926c874850876 - md5: 25f9aecd8efd2a2fd7b20bba5f75448f - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/greenlet?source=hash-mapping - size: 256387 - timestamp: 1784885502281 -- conda: https://conda.anaconda.org/conda-forge/win-64/greenlet-3.5.4-py314hb98de8c_0.conda - sha256: 4d300edd7057ee7c4b99a43556f7997554a97926fc2e0dda7b081eca55eb3ae8 - md5: 8e907d53bb8bee2257ff61a08d6ce811 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/greenlet?source=hash-mapping - size: 258534 - timestamp: 1784885504232 -- conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.8.0-py312he06e257_0.conda - sha256: 7a681d208c0b86ba38d754b6bcf6d4768158224563e6f838cfc25aea3a7d294d - md5: 54f018cf4a90780967493f77caa5f204 - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/httptools?source=hash-mapping - size: 76810 - timestamp: 1779757607289 -- conda: https://conda.anaconda.org/conda-forge/win-64/httptools-0.8.0-py314h5a2d7ad_0.conda - sha256: 1f425d4a1e2f823de14bffea27c7e4e8bcad88f2f172fc0918202a7e7d7756f3 - md5: 19d0766b98049e6fee351b80cb3e24d3 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/httptools?source=hash-mapping - size: 77486 - timestamp: 1779757611279 -- conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda - sha256: 75c549b55b673e15de8785a8e5dd85bca7eb612eee0ff4dc8d7bdaa15eacbdbb - md5: e596942e8ee6ee17fdcf1e6a77757a66 - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - license: MIT - license_family: MIT - purls: [] - size: 16835644 - timestamp: 1784916416303 -- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_1.conda - sha256: c55745796e762ba9e817ab1fc0f21f1a049e202f90fa762df39578f37923f6c2 - md5: 00335c2c4a98656554771aaf6f1a7400 - depends: - - openssl >=3.5.7,<4.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: [] - size: 750320 - timestamp: 1781859644591 -- conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-8_h8455456_mkl.conda - build_number: 8 - sha256: 43a87b59e6d4c68d80b2e4de487b1b54d66fe1f9a06636909b5a5ab9eae27269 - md5: 4a0ce24b1a946ff77ae9eaa7ef015a33 - depends: - - mkl >=2026.0.0,<2027.0a0 - constrains: - - libcblas 3.11.0 8*_mkl - - liblapacke 3.11.0 8*_mkl - - blas 2.308 mkl - - liblapack 3.11.0 8*_mkl - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 68103 - timestamp: 1779859688049 -- conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-8_h2a3cdd5_mkl.conda - build_number: 8 - sha256: 2a5b6555b481df4603e44cba49a6ef727584fd2f3c5235dd4bcb3028fffbdfb5 - md5: 09f1d8e4d2675d34ad2acb115211d10c - depends: - - libblas 3.11.0 8_h8455456_mkl - constrains: - - liblapacke 3.11.0 8*_mkl - - blas 2.308 mkl - - liblapack 3.11.0 8*_mkl - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 68443 - timestamp: 1779859701498 -- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.1-hac47afa_1.conda - sha256: 1a54d874addda73b6f7164d5f3905821277a1831bcc05edd74b3085391688571 - md5: ccc490c81ffe14181861beac0e8f3169 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - expat 2.8.1.* - license: MIT - license_family: MIT - purls: [] - size: 71631 - timestamp: 1781203724164 -- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.5.2-h3d046cb_0.conda - sha256: 59d01f2dfa8b77491b5888a5ab88ff4e1574c9359f7e229da254cdfe27ddc190 - md5: 720b39f5ec0610457b725eb3f396219a - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: [] - size: 45831 - timestamp: 1769456418774 -- conda: https://conda.anaconda.org/conda-forge/win-64/libgcc-15.2.0-h8ee18e1_20.conda - sha256: 954dcca1cbf2ad1e7ac2129bf77f787bec0a2eb9edb3f8b79c9a93b492a20c40 - md5: 00528c2577868b9cfefec85b8fe26d66 - depends: - - _openmp_mutex >=4.5 - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - constrains: - - libgcc-ng ==15.2.0=*_20 - - libgomp 15.2.0 h8ee18e1_20 - - msys2-conda-epoch <0.0a0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 820032 - timestamp: 1784925433987 -- conda: https://conda.anaconda.org/conda-forge/win-64/libgomp-15.2.0-h8ee18e1_20.conda - sha256: 33606594501174cc1677c7dbe39de739c2f04e9a8ddbd95aa82e48d1bd79b388 - md5: 3b2b211930103e49d77da1a7d9ea9af9 - depends: - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - constrains: - - msys2-conda-epoch <0.0a0 - license: GPL-3.0-only WITH GCC-exception-3.1 - license_family: GPL - purls: [] - size: 664776 - timestamp: 1784925374923 -- conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.13.0-default_h049141e_1000.conda - sha256: 2ee12e37223dfcd0acd050c80a91150c482b6e2899198521e1800dce66662467 - md5: 6a01c986e30292c715038d2788aa1385 - depends: - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - - libxml2 - - libxml2-16 >=2.14.6 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 2396128 - timestamp: 1770954127918 -- conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_2.conda - sha256: 0dcdb1a5f01863ac4e8ba006a8b0dc1a02d2221ec3319b5915a1863254d7efa7 - md5: 64571d1dd6cdcfa25d0664a5950fdaa2 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LGPL-2.1-only - purls: [] - size: 696926 - timestamp: 1754909290005 -- conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-8_hf9ab0e9_mkl.conda - build_number: 8 - sha256: 44999ed04bc0a56de44ee0ac8bd5b3702efd411a8b29491c0e3d3deb8619c94e - md5: d584799b920ecae9b75a2b70743a3de7 - depends: - - libblas 3.11.0 8_h8455456_mkl - constrains: - - libcblas 3.11.0 8*_mkl - - liblapacke 3.11.0 8*_mkl - - blas 2.308 mkl - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 81027 - timestamp: 1779859714698 -- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_0.conda - sha256: d636d1a25234063642f9c531a7bb58d84c1c496411280a36ea000bd122f078f1 - md5: 8f83619ab1588b98dd99c90b0bfc5c6d - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - xz 5.8.3.* - license: 0BSD - purls: [] - size: 106486 - timestamp: 1775825663227 -- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_1.conda - sha256: 40dcd0b9522a6e0af72a9db0ced619176e7cfdb114855c7a64f278e73f8a7514 - md5: e4a9fc2bba3b022dad998c78856afe47 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-2-Clause - license_family: BSD - purls: [] - size: 89411 - timestamp: 1769482314283 -- conda: https://conda.anaconda.org/conda-forge/win-64/libpq-18.4-h6b13b41_1.conda - sha256: 0b8e942a899c1d8c9157b0f539469f0e3cd912a7a0a8475c1cbb7bb8b59455b4 - md5: 05b3e79ec3bd2cc8cd982c09061b25d6 - depends: - - icu >=78.3,<79.0a0 - - krb5 >=1.22.2,<1.23.0a0 - - openssl >=3.5.7,<4.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: PostgreSQL - purls: [] - size: 4103932 - timestamp: 1782580980280 -- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_1.conda - sha256: de45b71224da77a1c3a7dd48d8885eb957c9f05455d4f0828463293e7144330f - md5: 7d5abf7ca1bd00b43d273f44d93d05dc - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - license: ISC - purls: [] - size: 280234 - timestamp: 1779164124739 -- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.53.4-hf5d6505_0.conda - sha256: 62e1c45ec71ab2e5deeeb0e47e7df6a609991e91d46348f16df50c68fee145c8 - md5: ca0d59f40a02a15e9b5d0ff8db0f85e3 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: blessing - purls: [] - size: 1313790 - timestamp: 1785016158097 -- conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda - sha256: 0fccf2d17026255b6e10ace1f191d0a2a18f2d65088fd02430be17c701f8ffe0 - md5: 8a86073cf3b343b87d03f41790d8b4e5 - depends: - - ucrt - constrains: - - pthreads-win32 <0.0a0 - - msys2-conda-epoch <0.0a0 - license: MIT AND BSD-3-Clause-Clear - purls: [] - size: 36621 - timestamp: 1759768399557 -- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.3-h3cfd58e_0.conda - sha256: 3b61ee3caba702d2ff432fa3920835db963026e5c99c4e6fdca0c6114f59e7ce - md5: 9e8dd0d90ed830107b2c36801035b7db - depends: - - icu >=78.3,<79.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.3,<6.0a0 - - libzlib >=1.3.2,<2.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - libxml2 2.15.3 - license: MIT - license_family: MIT - purls: [] - size: 519871 - timestamp: 1776376969852 -- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.3-h8ef44ab_0.conda - sha256: a4599c6bbbbdd7db570896e520c557eec8e66d94e839a59d17dc1f24a3d5f82b - md5: 95591ca5671d2213f5b2d5aa7818420d - depends: - - icu >=78.3,<79.0a0 - - libiconv >=1.18,<2.0a0 - - liblzma >=5.8.3,<6.0a0 - - libxml2-16 2.15.3 h3cfd58e_0 - - libzlib >=1.3.2,<2.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: [] - size: 43684 - timestamp: 1776376992865 -- conda: https://conda.anaconda.org/conda-forge/win-64/libxslt-1.1.43-h0fbe4c1_1.conda - sha256: 13da38939c2c20e7112d683ab6c9f304bfaf06230a2c6a7cf00359da1a003ec7 - md5: 46034d9d983edc21e84c0b36f1b4ba61 - depends: - - libxml2 - - libxml2-16 >=2.14.6 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: [] - size: 420223 - timestamp: 1757963935611 -- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_2.conda - sha256: 88609816e0cc7452bac637aaf65783e5edf4fee8a9f8e22bdc3a75882c536061 - md5: dbabbd6234dea34040e631f87676292f - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - zlib 1.3.2 *_2 - license: Zlib - license_family: Other - purls: [] - size: 58347 - timestamp: 1774072851498 -- conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-22.1.8-h4fa8253_0.conda - sha256: 50c02902bb516eeb56680358f052be38b5bf74b40e78ea4b2a675e84957e7307 - md5: de3551bf6508d45ca46b714639e52823 - depends: - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - openmp 22.1.8|22.1.8.* - - intel-openmp <0.0a0 - license: Apache-2.0 WITH LLVM-exception - license_family: APACHE - purls: [] - size: 348002 - timestamp: 1781737042070 -- conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.1-py312h2f35c63_0.conda - sha256: a23ee72837fa9606b5931b64c3d272303895b07bd654f291b3fb49f35ca1a3dc - md5: f23b7c90ac0cb5366be9715af546cfa1 - depends: - - libxml2 - - libxml2-16 >=2.14.6 - - libxslt >=1.1.43,<2.0a0 - - libzlib >=1.3.2,<2.0a0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-3-Clause and MIT-CMU - purls: - - pkg:pypi/lxml?source=hash-mapping - size: 1234695 - timestamp: 1779194942138 -- conda: https://conda.anaconda.org/conda-forge/win-64/lxml-6.1.1-py314hcdb55d9_0.conda - sha256: 0592cfa3f24bf8fa1980bcdec0aba6f532118374ec859b7429f2458aad6ae0a9 - md5: 8aafd5987b9bcfe357c44787fe2ed8c2 - depends: - - libxml2 - - libxml2-16 >=2.14.6 - - libxslt >=1.1.43,<2.0a0 - - libzlib >=1.3.2,<2.0a0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-3-Clause and MIT-CMU - purls: - - pkg:pypi/lxml?source=hash-mapping - size: 1236191 - timestamp: 1779194955487 -- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py312h05f76fc_1.conda - sha256: b744287a780211ac4595126ef96a44309c791f155d4724021ef99092bae4aace - md5: a73298d225c7852f97403ca105d10a13 - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - jinja2 >=3.0.0 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/markupsafe?source=hash-mapping - size: 28510 - timestamp: 1772445175216 -- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.3-py314h2359020_1.conda - sha256: 02805a0f3cd168dbf13afc5e4aed75cc00fe538ce143527a6471485b36f5887c - md5: 8de7b40f8b30a8fcaa423c2537fe4199 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - jinja2 >=3.0.0 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/markupsafe?source=hash-mapping - size: 30022 - timestamp: 1772445159549 -- conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_233.conda - sha256: ff355522fb0b6e33841167d9ca749147c8734d8be07b63b2ce25b0db043f42ed - md5: 5afbf28c4ca3e05b5469dbbd78c8e704 - depends: - - llvm-openmp >=22.1.8 - - onemkl-license 2026.1.0 h57928b3_233 - - tbb >=2023.0.0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LicenseRef-IntelSimplifiedSoftwareOct2022 - license_family: Proprietary - purls: [] - size: 114592547 - timestamp: 1783700769546 -- conda: https://conda.anaconda.org/conda-forge/win-64/mpc-1.4.0-hc817d3a_0.conda - sha256: 8090faa10d16151aa70fdf365ffd456155e0e14a7f1061b24fa866e8adaa5bea - md5: 7e3aa9f9d9a91457e744b12f70ecff53 - depends: - - gmp >=6.3.0,<7.0a0 - - libgcc >=14 - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - - mpfr >=4.2.2,<5.0a0 - - ucrt >=10.0.20348.0 - license: LGPL-3.0-or-later - license_family: LGPL - purls: [] - size: 141051 - timestamp: 1774472884039 -- conda: https://conda.anaconda.org/conda-forge/win-64/mpfr-4.2.2-h883a981_0.conda - sha256: 59d10b8dcfa2b899726556835b4546a2e1054f104c938b951ed9821fff81fad2 - md5: 7bcb237f435cb77e5a68d6e528ec65ae - depends: - - gmp >=6.3.0,<7.0a0 - - libgcc >=14 - - libwinpthread >=12.0.0.r4.gg4f2fc60ca - - ucrt >=10.0.20348.0 - license: LGPL-3.0-only - license_family: LGPL - purls: [] - size: 734113 - timestamp: 1773415369651 -- conda: https://conda.anaconda.org/conda-forge/win-64/nh3-0.3.6-py310ha413424_0.conda - noarch: python - sha256: aa8dffddfc42df86a87020294d4cb632b2e352dd1185cffd8b712afaaf28c531 - md5: 7a350d783a61aaa5adffbc6c43897600 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - _python_abi3_support 1.* - - cpython >=3.10 - license: MIT - license_family: MIT - purls: - - pkg:pypi/nh3?source=hash-mapping - size: 600627 - timestamp: 1782129887550 -- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-22.23.1-h80d1838_0.conda - sha256: 30da49e3d8cc1a3db4c6b0eff615759a409f62d23744742d749f2f229a2622a5 - md5: 11210dd5e2bb21937517539c749261c2 - license: MIT - license_family: MIT - purls: [] - size: 30054275 - timestamp: 1782396015445 -- conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.1-py312ha3f287d_0.conda - sha256: e9df7b016c6910a9d4b1894e53499de79a871292e2f88f338070f0fa0080fcaa - md5: 8ae08a63662c4b2d46af3f2917e749b4 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - liblapack >=3.9.0,<4.0a0 - - libcblas >=3.9.0,<4.0a0 - - python_abi 3.12.* *_cp312 - - libblas >=3.9.0,<4.0a0 - constrains: - - numpy-base <0a0 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/numpy?source=compressed-mapping - size: 7301536 - timestamp: 1783206237609 -- conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.1-py314h02f10f6_0.conda - sha256: 0f9f97b529480d17f7054783f7cdc163d0cdd537fabbb6b5bbae5c9439462c2f - md5: a67eeb19ff253ed3c4e094056e8fbb4c - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - liblapack >=3.9.0,<4.0a0 - - libcblas >=3.9.0,<4.0a0 - - libblas >=3.9.0,<4.0a0 - - python_abi 3.14.* *_cp314 - constrains: - - numpy-base <0a0 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/numpy?source=hash-mapping - size: 7452096 - timestamp: 1783206236616 -- conda: https://conda.anaconda.org/conda-forge/win-64/onemkl-license-2026.1.0-h57928b3_233.conda - sha256: 96dec1c878d6e6f835be8ed57152a37be9a5104ac79c2425815d71c64cf13ee4 - md5: 1566e2ce8c3bc23a2feb866c4d9e91e3 - license: LicenseRef-IntelSimplifiedSoftwareOct2022 - license_family: Proprietary - purls: [] - size: 53362 - timestamp: 1783700628723 -- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.3-hf411b9b_0.conda - sha256: cb6e7ba0d010ee0d3249ce9886de3d7613d26d9965d4c95666fa66b9c4c31001 - md5: e99f95734a326c0fd4d02bbd995150d4 - depends: - - ca-certificates - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: Apache - purls: [] - size: 9414790 - timestamp: 1781071745579 -- conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py312hc128f0a_2.conda - sha256: 7f37f3ccea378f491f68979c7afd7f2dbc8ee83c3461dfab3cce15d436298f44 - md5: 57d80e87a8b3161bcf26472deceaa556 - depends: - - numpy >=1.22.4 - - numpy >=1.23,<3 - - python >=3.12,<3.13.0a0 - - python-dateutil >=2.8.2 - - python-tzdata >=2022.7 - - python_abi 3.12.* *_cp312 - - pytz >=2020.1 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - blosc >=1.21.3 - - qtpy >=2.3.0 - - pandas-gbq >=0.19.0 - - lxml >=4.9.2 - - fsspec >=2022.11.0 - - xarray >=2022.12.0 - - gcsfs >=2022.11.0 - - tabulate >=0.9.0 - - numba >=0.56.4 - - xlrd >=2.0.1 - - html5lib >=1.1 - - beautifulsoup4 >=4.11.2 - - pyqt5 >=5.15.9 - - openpyxl >=3.1.0 - - zstandard >=0.19.0 - - psycopg2 >=2.9.6 - - bottleneck >=1.3.6 - - pytables >=3.8.0 - - pyreadstat >=1.2.0 - - python-calamine >=0.1.7 - - pyarrow >=10.0.1 - - s3fs >=2022.11.0 - - matplotlib >=3.6.3 - - pyxlsb >=1.0.10 - - tzdata >=2022.7 - - odfpy >=1.4.1 - - sqlalchemy >=2.0.0 - - scipy >=1.10.0 - - xlsxwriter >=3.0.5 - - fastparquet >=2022.12.0 - - numexpr >=2.8.4 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/pandas?source=hash-mapping - size: 13779090 - timestamp: 1764615170494 -- conda: https://conda.anaconda.org/conda-forge/win-64/pandas-2.3.3-py314hd8fd7ce_2.conda - sha256: a1c87d34f72d6ae3f78203c60cf1b1adfb8d5cf55a3fc90f47e9f9ed50eb8b91 - md5: 95cf7fc22f898b6faeb1d62ce2f5b82c - depends: - - numpy >=1.22.4 - - numpy >=1.23,<3 - - python >=3.14,<3.15.0a0 - - python-dateutil >=2.8.2 - - python-tzdata >=2022.7 - - python_abi 3.14.* *_cp314 - - pytz >=2020.1 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - scipy >=1.10.0 - - sqlalchemy >=2.0.0 - - fsspec >=2022.11.0 - - pyreadstat >=1.2.0 - - gcsfs >=2022.11.0 - - tabulate >=0.9.0 - - openpyxl >=3.1.0 - - pytables >=3.8.0 - - qtpy >=2.3.0 - - matplotlib >=3.6.3 - - bottleneck >=1.3.6 - - python-calamine >=0.1.7 - - numba >=0.56.4 - - beautifulsoup4 >=4.11.2 - - tzdata >=2022.7 - - xarray >=2022.12.0 - - pyqt5 >=5.15.9 - - odfpy >=1.4.1 - - xlrd >=2.0.1 - - pyarrow >=10.0.1 - - s3fs >=2022.11.0 - - psycopg2 >=2.9.6 - - pandas-gbq >=0.19.0 - - xlsxwriter >=3.0.5 - - fastparquet >=2022.12.0 - - numexpr >=2.8.4 - - zstandard >=0.19.0 - - lxml >=4.9.2 - - pyxlsb >=1.0.10 - - html5lib >=1.1 - - blosc >=1.21.3 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/pandas?source=hash-mapping - size: 14046781 - timestamp: 1764615388271 -- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py312he5662c2_0.conda - sha256: edffc84c001a05b996b5f8607c8164432754e86ec9224e831cd00ebabdec04e7 - md5: a2724c93b745fc7861948eb8b9f6679a - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/psutil?source=hash-mapping - size: 242769 - timestamp: 1769678170631 -- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_0.conda - sha256: 17c8274ce5a32c9793f73a5a0094bd6188f3a13026a93147655143d4df034214 - md5: fd539ac231820f64066839251aa9fa48 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/psutil?source=hash-mapping - size: 249950 - timestamp: 1769678167309 -- conda: https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.10-py312h2f859cb_4.conda - sha256: 1abe0e4b590840d439519499f4e66779a8234f3eadc01b55ecc0346232dfaff2 - md5: e3a6ede81b9a27490e0577e26f23b2f9 - depends: - - libpq >=18.1,<19.0a0 - - openssl >=3.5.4,<4.0a0 - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LGPL-3.0-or-later - license_family: LGPL - purls: - - pkg:pypi/psycopg2?source=hash-mapping - size: 170277 - timestamp: 1763596156111 -- conda: https://conda.anaconda.org/conda-forge/win-64/psycopg2-2.9.10-py314h3cd40a6_4.conda - sha256: ec3f4bf11037c64c4a6e709e61998dce95d26d7fea9805d03db264955eb8758c - md5: 6d93bfd3d5990b8afdc7e202443435ea - depends: - - libpq >=18.1,<19.0a0 - - openssl >=3.5.4,<4.0a0 - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: LGPL-3.0-or-later - license_family: LGPL - purls: - - pkg:pypi/psycopg2?source=hash-mapping - size: 172253 - timestamp: 1763596062289 -- conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py312hdabe01f_0.conda - sha256: 2b251fb56d07efd36815732477d22bcdd2c7a6e7500012eb024d8c59413d2e24 - md5: edd458c91ab9bc198012c73289b0b0bc - depends: - - python - - typing-extensions >=4.6.0,!=4.7.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pydantic-core?source=hash-mapping - size: 1888918 - timestamp: 1778084258226 -- conda: https://conda.anaconda.org/conda-forge/win-64/pydantic-core-2.46.4-py314h9f07db2_0.conda - sha256: 53621d25961c97ef77d7d03b8e0d479ee0bb8135b51d71e99a5ed7713a229f5f - md5: a5a21f85bcee70d505798d56750d69cf - depends: - - python - - typing-extensions >=4.6.0,!=4.7.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pydantic-core?source=hash-mapping - size: 1885001 - timestamp: 1778084256232 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyspnego-0.12.1-py312h2e8e312_0.conda - sha256: 55b2e775f6bfed976b54bf2d9818e4981807c66d1bce1d67124f69af7b7c23a4 - md5: 65624d1fcadb4b862b18fd28bd2274d0 - depends: - - cryptography - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ruamel.yaml - - sspilib >=0.3.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyspnego?source=hash-mapping - size: 310080 - timestamp: 1772896929796 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyspnego-0.12.1-py314h86ab7b2_0.conda - sha256: 7f766eda82b12cc5b27709e7df5c6392478c746fcc745c529e6d01deb41b3fcb - md5: c3fe3d0d82a3936da209d6dbd71ea46c - depends: - - cryptography - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ruamel.yaml - - sspilib >=0.3.0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyspnego?source=hash-mapping - size: 328878 - timestamp: 1772896926485 -- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.13-h0159041_0_cpython.conda - sha256: a02b446d8b7b167b61733a3de3be5de1342250403e72a63b18dac89e99e6180e - md5: 2956dff38eb9f8332ad4caeba941cfe7 - depends: - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.7.4,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.2,<6.0a0 - - libsqlite >=3.51.2,<4.0a0 - - libzlib >=1.3.1,<2.0a0 - - openssl >=3.5.5,<4.0a0 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - constrains: - - python_abi 3.12.* *_cp312 - license: Python-2.0 - purls: [] - size: 15840187 - timestamp: 1772728877265 -- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.6-h4b44e0e_101_cp314.conda - build_number: 101 - sha256: 3a9ae901cd853d507d97aa8b72af4b9a572a3f92dcc5bad8a1318f77ff4e0e64 - md5: 67bbf51f88a2053513d7c78f485f7479 - depends: - - bzip2 >=1.0.8,<2.0a0 - - libexpat >=2.8.1,<3.0a0 - - libffi >=3.5.2,<3.6.0a0 - - liblzma >=5.8.3,<6.0a0 - - libmpdec >=4.0.0,<5.0a0 - - libsqlite >=3.53.3,<4.0a0 - - libzlib >=1.3.2,<2.0a0 - - openssl >=3.5.7,<4.0a0 - - python_abi 3.14.* *_cp314 - - tk >=8.6.13,<8.7.0a0 - - tzdata - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - zstd >=1.5.7,<1.6.0a0 - license: Python-2.0 - purls: [] - size: 18338767 - timestamp: 1784911044838 - python_site_packages_path: Lib/site-packages -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py312h829343e_0.conda - sha256: e4560c30234075bf17c641cb651279ff6c6f2bad581dfc37ed780f159909b2d3 - md5: 47f425e058b0a4b66712784a0d24b45d - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: PSF-2.0 - license_family: PSF - purls: - - pkg:pypi/pywin32?source=compressed-mapping - size: 4459779 - timestamp: 1781362887119 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-312-py314hcaaf0b2_0.conda - sha256: 71f9617186f5e2273bcefc4f3a258df5784886077ad36fe14f1d16b1c2c506eb - md5: 4d49ac91c23c4b1878daf5ea9e7c767a - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: PSF-2.0 - license_family: PSF - purls: - - pkg:pypi/pywin32?source=hash-mapping - size: 4472831 - timestamp: 1781362876741 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywin32-ctypes-0.2.3-py314h86ab7b2_3.conda - sha256: 70b43b8d6ac68a524e4d9dd0caf98f6c052918c1b658ee80af9e0269e2bc3a2a - md5: 2507b24a127696b044f441df16c5571c - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/pywin32-ctypes?source=hash-mapping - size: 58083 - timestamp: 1762489935449 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py312h275cf98_1.conda - sha256: 61cc6c2c712ab4d2b8e7a73d884ef8d3262cb80cc93a4aa074e8b08aa7ddd648 - md5: 66255d136bd0daa41713a334db41d9f0 - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 - - winpty - license: MIT - license_family: MIT - purls: - - pkg:pypi/pywinpty?source=hash-mapping - size: 215371 - timestamp: 1759557609855 -- conda: https://conda.anaconda.org/conda-forge/win-64/pywinpty-2.0.15-py314h51f0985_1.conda - sha256: 048e20641da680aedaab285640a2aca56b7b5baf7a18f8f164f2796e13628c1f - md5: dd84e8748bd3c85a5c751b0576488080 - depends: - - python >=3.14.0rc3,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.2,<15 - - vc14_runtime >=14.29.30139 - - winpty - license: MIT - license_family: MIT - purls: - - pkg:pypi/pywinpty?source=hash-mapping - size: 216325 - timestamp: 1759557436167 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py312h05f76fc_1.conda - sha256: 1cab6cbd6042b2a1d8ee4d6b4ec7f36637a41f57d2f5c5cf0c12b7c4ce6a62f6 - md5: 9f6ebef672522cb9d9a6257215ca5743 - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - yaml >=0.2.5,<0.3.0a0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyyaml?source=hash-mapping - size: 179738 - timestamp: 1770223468771 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda - sha256: a2aff34027aa810ff36a190b75002d2ff6f9fbef71ec66e567616ac3a679d997 - md5: 0cd9b88826d0f8db142071eb830bce56 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - yaml >=0.2.5,<0.3.0a0 - license: MIT - license_family: MIT - purls: - - pkg:pypi/pyyaml?source=hash-mapping - size: 181257 - timestamp: 1770223460931 -- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.1.0-py312h343a6d4_3.conda - noarch: python - sha256: d7e65c44ea8a92f80cc0e424b4b7dbe63b8a9ec04ea774b7d4f7aed4c34cce4c - md5: ebbda9a4e5161d6e1f98146ad057dc10 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - _python_abi3_support 1.* - - cpython >=3.12 - - zeromq >=4.3.5,<4.3.6.0a0 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/pyzmq?source=compressed-mapping - size: 182831 - timestamp: 1779483925948 -- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py312hd944d65_0.conda - sha256: 8df011d21a56a4fb9aa0d5b27c2d0138058a4d82af532723e1002a643c02dead - md5: 4a097aa0b62666ceb97a5ed5b9345131 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/rpds-py?source=compressed-mapping - size: 217956 - timestamp: 1782831653430 -- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.6.3-py314hc980628_0.conda - sha256: ac1047c2a9bb3dc7cac31b7c995a7b25f9a4eb52ee8ae106ca6e6c15679f34fc - md5: 52429bdb1752b43f8364bc482cf65fda - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/rpds-py?source=hash-mapping - size: 217789 - timestamp: 1782831449604 -- conda: https://conda.anaconda.org/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py312he5662c2_1.conda - sha256: a28bd33ef3380c44632d6ead75a6ec170e135f18941f4b1d77f1cc1b24c1dc02 - md5: cc0977464335ea5c2e5ee3d00458e0c2 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/ruamel-yaml-clib?source=hash-mapping - size: 105961 - timestamp: 1766159551536 -- conda: https://conda.anaconda.org/conda-forge/win-64/ruamel.yaml.clib-0.2.15-py314hc5dbbe4_1.conda - sha256: b719637ce71e533193cd2bcacbf6ba5c10deaafa1be90d96040ee2314c6b17d1 - md5: 496de351b0f9afe9e245229528304f25 - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/ruamel-yaml-clib?source=hash-mapping - size: 105668 - timestamp: 1766159584330 -- conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py312he5662c2_0.conda - sha256: 04591df0e1ff82662f65d5cfb2d643063d1c90f5c962792ccc00db4ca3f22d22 - md5: e1c72593933d9d16f603f67cc1cadf29 - depends: - - python - - greenlet !=0.4.17 - - typing-extensions >=4.6.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/sqlalchemy?source=hash-mapping - size: 3668574 - timestamp: 1781547906603 -- conda: https://conda.anaconda.org/conda-forge/win-64/sqlalchemy-2.0.51-py314hc5dbbe4_0.conda - sha256: 55e98a0f4e491151ffad7c274d22dfb2d999d50cafa606ec649eff29f079591b - md5: 920cf560266c77de21a8f55e7d0e0329 - depends: - - python - - greenlet !=0.4.17 - - typing-extensions >=4.6.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/sqlalchemy?source=hash-mapping - size: 3986755 - timestamp: 1781547909164 -- conda: https://conda.anaconda.org/conda-forge/win-64/sspilib-0.5.0-py312he06e257_1.conda - sha256: 5e1b9f60bc6eac319ab7235075dc7057ad9ff94cdf30fe052d29ea77fcc58704 - md5: 98924fb858f9d8a18745e6f8efdd743f - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/sspilib?source=hash-mapping - size: 283281 - timestamp: 1770027574853 -- conda: https://conda.anaconda.org/conda-forge/win-64/sspilib-0.5.0-py314h5a2d7ad_1.conda - sha256: 49683bb7d95d0e88c6f54b5ef972a96ff0c9db660bb70b0ec3b28f4222f1ad38 - md5: 781a4f7b62e84c088008fbce4ecf0f0a - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: MIT - license_family: MIT - purls: - - pkg:pypi/sspilib?source=hash-mapping - size: 285437 - timestamp: 1770027579320 -- conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.0.0-hd3d4ead_2.conda - sha256: 8a4053839b8e997a5965e2dff7d6cf3c77be62d82c0e48c8a04a5ed2d2e73035 - md5: 8ee01a693aecff5432069eaaf1183c45 - depends: - - libhwloc >=2.13.0,<2.13.1.0a0 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: APACHE - purls: [] - size: 156515 - timestamp: 1778673901757 -- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_3.conda - sha256: 13fa29257d43f8e630a1e591ed77fae9bbbb236b011432f01e2034cf36e6bf03 - md5: aaf79e2af50a151fb5b5a3e3f38b7a69 - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - license: TCL - purls: [] - size: 3782314 - timestamp: 1784229072899 -- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py312he06e257_0.conda - sha256: 110cf0c741e181ed929a2acdb488f2095badad973c50dd696af36c4162e063cf - md5: 1045d29f787812d3fac1fd80a1339710 - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: Apache - purls: - - pkg:pypi/tornado?source=compressed-mapping - size: 865801 - timestamp: 1781006895319 -- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.7-py314h5a2d7ad_0.conda - sha256: 6b9f5a195ca148f7c6b9a4a0a026631979b3112c43cd7c1064085ff833dfa4f0 - md5: b1b9bf11a82e608c5649d7462de94c5f - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: Apache-2.0 - license_family: Apache - purls: - - pkg:pypi/tornado?source=hash-mapping - size: 919275 - timestamp: 1781006902968 -- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda - sha256: 3005729dce6f3d3f5ec91dfc49fc75a0095f9cd23bab49efb899657297ac91a5 - md5: 71b24316859acd00bdb8b38f5e2ce328 - constrains: - - vc14_runtime >=14.29.30037 - - vs2015_runtime >=14.29.30037 - license: LicenseRef-MicrosoftWindowsSDK10 - purls: [] - size: 694692 - timestamp: 1756385147981 -- conda: https://conda.anaconda.org/conda-forge/win-64/uv-0.11.32-h7ca4a90_0.conda - sha256: 063ed854de19c2d4ab4861aff39016f25c0e9011e8a196728e62241927193345 - md5: 38604ac478f3fd2aebbd08d648915669 - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - license: Apache-2.0 OR MIT - purls: [] - size: 22051025 - timestamp: 1784989836403 -- conda: https://conda.anaconda.org/conda-forge/win-64/vc-14.5-h1b7c187_39.conda - sha256: 17693b60cb54f80c60275f003f3bfc1b128af56dbfd65c4fae37c64eeb755ce1 - md5: 2eacea63f545b97342da520df6854276 - depends: - - vc14_runtime >=14.51.36231 - track_features: - - vc14 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 20362 - timestamp: 1781320968457 -- conda: https://conda.anaconda.org/conda-forge/win-64/vc14_runtime-14.51.36231-h1b9f54f_39.conda - sha256: 8153ed849c92e891eacac0f2f8d7ecb79f9b5fd7f7917fbb896f252a60a40390 - md5: 06a5bf5a1ca16cce0df6eaa91fc42bc2 - depends: - - ucrt >=10.0.20348.0 - - vcomp14 14.51.36231 h1b9f54f_39 - constrains: - - vs2015_runtime 14.51.36231.* *_39 - license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime - license_family: Proprietary - purls: [] - size: 737434 - timestamp: 1781320964561 -- conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36231-h1b9f54f_39.conda - sha256: 07fb14713c4bc62e2533a2e23a363abfb0e65650681fba0ae4c840e2219350f3 - md5: 8b53a83fda40ec679e4d63fa32fae989 - depends: - - ucrt >=10.0.20348.0 - constrains: - - vs2015_runtime 14.51.36231.* *_39 - license: LicenseRef-MicrosoftVisualCpp2015-2022Runtime - license_family: Proprietary - purls: [] - size: 120684 - timestamp: 1781320948530 -- conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.2.0-py312hd944d65_1.conda - sha256: 2a395023f2707ed89ffbddfdb6231aacdea77a1522c866aca8abbd8142922723 - md5: 9feea00e9fee5701c716316f0a85f167 - depends: - - python - - anyio >=3.0.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: MIT - license_family: MIT - purls: - - pkg:pypi/watchfiles?source=hash-mapping - size: 348780 - timestamp: 1781180334653 -- conda: https://conda.anaconda.org/conda-forge/win-64/watchfiles-1.2.0-py314hc980628_1.conda - sha256: 262150bdbbecbe49096ef90b8233412d6134f4d43168e7e5c530d8b3252f6cb4 - md5: 5654b0a18935e2baecb89e33e2d6a530 - depends: - - python - - anyio >=3.0.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: MIT - license_family: MIT - purls: - - pkg:pypi/watchfiles?source=hash-mapping - size: 351309 - timestamp: 1781180337205 -- conda: https://conda.anaconda.org/conda-forge/win-64/websockets-16.1.1-py312h5247d40_0.conda - sha256: 5c220eb236a1fd41c22b7fd54077901452ec51724c1e6e8a7cbf9b0884352a95 - md5: 9fd7a8567836c92579d4861da22dd25a - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.12.* *_cp312 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/websockets?source=hash-mapping - size: 419546 - timestamp: 1784377088338 -- conda: https://conda.anaconda.org/conda-forge/win-64/websockets-16.1.1-py314h13f4da2_0.conda - sha256: a2d80d7777849355cee5b8c507c326e1226dbc5a4a5d81fb432b6f0841c3f3c2 - md5: dcd30ef6a92836606e0e9ba5c120bdce - depends: - - python - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - python_abi 3.14.* *_cp314 - license: BSD-3-Clause - license_family: BSD - purls: - - pkg:pypi/websockets?source=hash-mapping - size: 445139 - timestamp: 1784377078555 -- conda: https://conda.anaconda.org/conda-forge/win-64/winpty-0.4.3-4.tar.bz2 - sha256: 9df10c5b607dd30e05ba08cbd940009305c75db242476f4e845ea06008b0a283 - md5: 1cee351bf20b830d991dbe0bc8cd7dfe - license: MIT - license_family: MIT - purls: [] - size: 1176306 -- conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.2.2-py312he06e257_0.conda - sha256: 8fa16daca416ab83f64b8731c33a6f211f4e703f02d03c7eabda45a6a3320657 - md5: 573f686209e82c06daef1e43ee79975f - depends: - - python >=3.12,<3.13.0a0 - - python_abi 3.12.* *_cp312 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/wrapt?source=hash-mapping - size: 113162 - timestamp: 1782133745435 -- conda: https://conda.anaconda.org/conda-forge/win-64/wrapt-2.2.2-py314h5a2d7ad_0.conda - sha256: 93cbcaf37eff31ccb2cdaca883f8be991e7c5098b925e8372c3ac615fef47197 - md5: db00af109424ec883fe7d239a4ccee44 - depends: - - python >=3.14,<3.15.0a0 - - python_abi 3.14.* *_cp314 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - license: BSD-2-Clause - license_family: BSD - purls: - - pkg:pypi/wrapt?source=hash-mapping - size: 115544 - timestamp: 1782133735742 -- conda: https://conda.anaconda.org/conda-forge/win-64/yaml-0.2.5-h6a83c73_3.conda - sha256: 80ee68c1e7683a35295232ea79bcc87279d31ffeda04a1665efdb43cbd50a309 - md5: 433699cba6602098ae8957a323da2664 - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - license: MIT - license_family: MIT - purls: [] - size: 63944 - timestamp: 1753484092156 -- conda: https://conda.anaconda.org/conda-forge/win-64/zeromq-4.3.5-h3a581c9_11.conda - sha256: c3e279cb309b153152fcdd6ee6d039ad996d563c849f06be39d85b8e3351df25 - md5: f016c0c5f9c01549b259146614786192 - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - libsodium >=1.0.22,<1.0.23.0a0 - - krb5 >=1.22.2,<1.23.0a0 - license: MPL-2.0 - license_family: MOZILLA - purls: [] - size: 265717 - timestamp: 1779124031378 -- conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_6.conda - sha256: 368d8628424966fd8f9c8018326a9c779e06913dd39e646cf331226acc90e5b2 - md5: 053b84beec00b71ea8ff7a4f84b55207 - depends: - - vc >=14.3,<15 - - vc14_runtime >=14.44.35208 - - ucrt >=10.0.20348.0 - - libzlib >=1.3.1,<2.0a0 - license: BSD-3-Clause - license_family: BSD - purls: [] - size: 388453 - timestamp: 1764777142545 - pypi: . name: fileglancer requires_dist: @@ -16024,21 +13256,6 @@ packages: - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl - name: frozenlist - version: 1.8.0 - sha256: 3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 - requires_python: '>=3.9' -- pypi: https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl - name: propcache - version: 0.5.2 - sha256: d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl - name: propcache - version: 0.5.2 - sha256: 81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl name: propcache version: 0.5.2 @@ -16079,25 +13296,6 @@ packages: version: 2.7.1 sha256: 9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl - name: aiohttp - version: 3.14.3 - sha256: 33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl name: yarl version: 1.24.5 @@ -16107,13 +13305,6 @@ packages: - multidict>=4.0 - propcache>=0.2.1 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl - name: multidict - version: 6.7.1 - sha256: fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19 - requires_dist: - - typing-extensions>=4.1.0 ; python_full_version < '3.11' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/84/47/07046e0acedc12fe2bae79cf6c73ad67f51ae9d67df64d06b0f3eac73d36/pytest_html-4.2.0-py3-none-any.whl name: pytest-html version: 4.2.0 @@ -16255,11 +13446,6 @@ packages: requires_dist: - hatchling>=0.21.0 requires_python: '>=3.7' -- pypi: https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl - name: frozenlist - version: 1.8.0 - sha256: 34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746 - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl name: s3transfer version: 0.19.2 @@ -16268,24 +13454,6 @@ packages: - botocore>=1.37.4,<2.0a0 - botocore[crt]>=1.37.4,<2.0a0 ; extra == 'crt' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl - name: yarl - version: 1.24.5 - sha256: a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl - name: yarl - version: 1.24.5 - sha256: 5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad - requires_dist: - - idna>=2.0 - - multidict>=4.0 - - propcache>=0.2.1 - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl name: x2s3 version: 1.5.2 @@ -16346,13 +13514,6 @@ packages: - multidict>=4.0 - propcache>=0.2.1 requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl - name: multidict - version: 6.7.1 - sha256: 5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f - requires_dist: - - typing-extensions>=4.1.0 ; python_full_version < '3.11' - requires_python: '>=3.9' - pypi: https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl name: propcache version: 0.5.2 @@ -16403,25 +13564,6 @@ packages: - pytest-asyncio ; extra == 'test' - pytest-cov ; extra == 'test' requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl - name: aiohttp - version: 3.14.3 - sha256: 8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 - requires_dist: - - aiohappyeyeballs>=2.5.0 - - aiosignal>=1.4.0 - - async-timeout>=4.0,<6.0 ; python_full_version < '3.11' - - attrs>=17.3.0 - - frozenlist>=1.1.1 - - multidict>=4.5,<7.0 - - propcache>=0.2.0 - - typing-extensions>=4.4 ; python_full_version < '3.13' - - yarl>=1.17.0,<2.0 - - aiodns>=3.3.0 ; sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotli>=1.2 ; platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups' - - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and sys_platform != 'android' and sys_platform != 'ios' and extra == 'speedups' - requires_python: '>=3.10' - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl name: aiosignal version: 1.4.0 diff --git a/pyproject.toml b/pyproject.toml index 5c2185674..8740f63ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ ignore = ["W002"] [tool.pixi.workspace] channels = ["conda-forge"] -platforms = ["osx-arm64", "osx-64", "linux-64", "linux-aarch64", "win-64"] +platforms = ["osx-arm64", "osx-64", "linux-64", "linux-aarch64"] [tool.pixi.feature.test.tasks] test-pip-install = { cmd = "pip install -e .", default-environment = "test" } diff --git a/tests/test_apps.py b/tests/test_apps.py index 1f4b271ac..cbd4e2e2c 100644 --- a/tests/test_apps.py +++ b/tests/test_apps.py @@ -32,19 +32,6 @@ expand_user_path, ) -# The `pwd` module is POSIX-only; on Windows `command_mod.pwd` is None, so tests -# that patch pwd.getpwnam/getpwuid to exercise per-user home resolution cannot run. -requires_pwd = pytest.mark.skipif( - sys.platform == "win32", reason="pwd module is not available on Windows" -) - -# The generated script snippets are POSIX bash and only ever run on Linux -# compute nodes; on Windows CI runners `bash` resolves to the WSL stub, which -# can't execute them. -requires_bash = pytest.mark.skipif( - sys.platform == "win32", reason="requires a working bash; snippets only run on Linux compute nodes" -) - # --- Model tests --- @@ -155,10 +142,6 @@ def _run_check(reqs, extra_path=None, prefix=""): return proc.returncode, proc.stderr.strip() -@pytest.mark.skipif( - sys.platform == "win32", - reason="requirement-check snippet is POSIX bash; it only runs on Linux compute nodes", -) class TestBuildRequirementsCheck: def test_empty_returns_empty_string(self): assert build_requirements_check([]) == "" @@ -914,7 +897,6 @@ def test_helper_exports_port_hostname_and_token(self): assert "export FG_HOSTNAME=" in _SERVICE_PORT_HELPER assert "export FG_SERVICE_TOKEN=" in _SERVICE_PORT_HELPER - @requires_bash def test_helper_mints_a_urlsafe_token(self): import re script = _SERVICE_PORT_HELPER + '\necho "$FG_SERVICE_TOKEN"' @@ -924,7 +906,6 @@ def test_helper_mints_a_urlsafe_token(self): # Non-empty and URL-safe (hex), so no encoding is needed in the URL. assert re.fullmatch(r"[0-9a-f]{16,}", token), token - @requires_bash def test_helper_is_valid_bash(self): # The generated snippet must at least parse as bash. result = subprocess.run( @@ -933,7 +914,6 @@ def test_helper_is_valid_bash(self): ) assert result.returncode == 0, result.stderr - @requires_bash def test_helper_picks_a_free_port_at_runtime(self): # Run the helper and confirm FG_SERVICE_PORT is a plausible TCP port. script = _SERVICE_PORT_HELPER + '\necho "$FG_SERVICE_PORT"' @@ -944,7 +924,6 @@ def test_helper_picks_a_free_port_at_runtime(self): port = int(result.stdout.strip().splitlines()[-1]) assert 1 <= port <= 65535 - @requires_bash def test_helper_robust_under_set_euo_pipefail(self): # A deployment may prepend `set -euo pipefail` via script_prologue; the # helper must still allocate a port and not abort the job. @@ -1014,7 +993,6 @@ def test_requires_auto_url(self): class TestServiceUrlPublisher: """The backgrounded readiness probe that publishes SERVICE_URL_PATH.""" - @requires_bash def test_publisher_is_valid_bash(self): snippet = _build_service_url_publisher("/?access_token=${FG_SERVICE_TOKEN}") result = subprocess.run(["bash", "-n", "-c", snippet], @@ -1022,7 +1000,6 @@ def test_publisher_is_valid_bash(self): assert result.returncode == 0, result.stderr assert "SERVICE_URL_PATH" in snippet and "3600" in snippet - @requires_bash def test_publishes_tokenized_url_only_once_port_is_up(self, tmp_path): import socket # Bind a real port so the probe's TCP connect succeeds. @@ -1048,7 +1025,6 @@ def test_publishes_tokenized_url_only_once_port_is_up(self, tmp_path): finally: srv.close() - @requires_bash def test_does_not_publish_when_port_never_opens(self, tmp_path): import socket # Grab a free port number, then close it so nothing is listening. @@ -1268,7 +1244,6 @@ def test_absolute_path_unchanged(self, entry_point): cmd = build_command(entry_point, {"output_dir": "/data/output"}) assert "/data/output" in cmd - @requires_pwd def test_tilde_expanded_to_target_user_home(self, entry_point, monkeypatch): """With a username, ~ resolves to that user's home, not the server's.""" import fileglancer.apps.command as command_mod @@ -1524,10 +1499,6 @@ def test_uri_unchanged(self): def test_absolute_unchanged(self): assert expand_user_path("/data/output") == "/data/output" - def test_backslashes_normalized(self): - assert expand_user_path("/data\\sub") == "/data/sub" - - @requires_pwd def test_tilde_uses_username_home(self, monkeypatch): import fileglancer.apps.command as command_mod monkeypatch.setattr(command_mod.pwd, "getpwnam", @@ -1535,7 +1506,6 @@ def test_tilde_uses_username_home(self, monkeypatch): assert expand_user_path("~/x", username="bob") == "/home/bob/x" assert expand_user_path("~", username="bob") == "/home/bob" - @requires_pwd def test_unknown_username_falls_back_to_euid(self, monkeypatch): import fileglancer.apps.command as command_mod monkeypatch.setattr(command_mod.pwd, "getpwnam", @@ -2640,10 +2610,6 @@ def test_pyproject_toml(self, tmp_path): assert manifest.source_filename == "pyproject.toml" -@pytest.mark.skipif( - sys.platform == "win32", - reason="the poll loop uses fcntl file locking; the server only runs on POSIX", -) class TestPollLoopStopRace: """The poll loop must not orphan a job submitted while it is stopping. diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py index c85cbf21d..368f95568 100644 --- a/tests/test_client_paths.py +++ b/tests/test_client_paths.py @@ -146,8 +146,7 @@ def test_abspath_normalizes_windows_separators(fg): _resolve normalizes backslashes to '/' on the way in, so abspath has to do the same on the way out or a Windows mount path joined to a '/'-separated - relative path comes back as 'C:/shares/data\\sub/file.txt'. Caught by - Windows CI, where mount_path really does contain backslashes. + relative path comes back as 'C:/shares/data\\sub/file.txt'. """ fg._fsp_cache = [ FileSharePath(name="winshare", zone="z", mount_path="C:\\shares\\data") diff --git a/tests/test_filestore.py b/tests/test_filestore.py index 18997f613..f69a7ea50 100644 --- a/tests/test_filestore.py +++ b/tests/test_filestore.py @@ -1,6 +1,5 @@ import os import stat -import sys import pytest import tempfile import shutil @@ -164,21 +163,18 @@ def test_create_empty_file(filestore, test_dir): assert os.path.exists(os.path.join(test_dir, "newfile.txt")) -@pytest.mark.skipif(sys.platform == "win32", reason="Windows chmod does not support full Unix permission bits") def test_change_file_permissions(filestore, test_dir): filestore.change_file_permissions("test.txt", "-rw-r--r--") fullpath = os.path.join(test_dir, "test.txt") assert stat.S_IMODE(os.stat(fullpath).st_mode) == 0o644 -@pytest.mark.skipif(sys.platform == "win32", reason="Windows chmod does not support full Unix permission bits") def test_change_file_permissions_with_execute(filestore, test_dir): filestore.change_file_permissions("test.txt", "-rwxr-xr-x") fullpath = os.path.join(test_dir, "test.txt") assert stat.S_IMODE(os.stat(fullpath).st_mode) == 0o755 -@pytest.mark.skipif(sys.platform == "win32", reason="Windows chmod does not support full Unix permission bits") def test_change_dir_permissions_with_sticky_bit(filestore, test_dir): subdir = os.path.join(test_dir, "sticky_dir") os.makedirs(subdir, exist_ok=True) @@ -186,7 +182,6 @@ def test_change_dir_permissions_with_sticky_bit(filestore, test_dir): assert stat.S_IMODE(os.stat(subdir).st_mode) == 0o1777 -@pytest.mark.skipif(sys.platform == "win32", reason="Windows chmod does not support full Unix permission bits") def test_change_dir_permissions_sticky_without_execute(filestore, test_dir): subdir = os.path.join(test_dir, "sticky_dir2") os.makedirs(subdir, exist_ok=True) diff --git a/tests/test_worker.py b/tests/test_worker.py index 4e5bf4f91..d5a28aaa8 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -11,17 +11,11 @@ import os import socket import struct -import sys import tempfile import time import pytest -pytestmark = pytest.mark.skipif( - sys.platform == "win32", - reason="Worker subsystem uses fork/setuid/SCM_RIGHTS (Unix-only)", -) - from conftest import requires_symlinks from fileglancer.user_worker import ( _send, From 17386467f861557bbb8db97fa706f2673e5bac40 Mon Sep 17 00:00:00 2001 From: Konrad Rokicki Date: Fri, 28 Aug 2026 14:08:25 -0400 Subject: [PATCH 43/43] fix: drop a stray comment that clobbered the cli.py shebang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3a61c428 overwrote line 1 of cli.py, replacing `#!/usr/bin/env python3` with `#leaned up CLI auto-login session` — a commit-message fragment from a botched edit. The shebang was vestigial (the file is not executable and the CLI runs via the `fileglancer` console script), so drop the line rather than restore it. Co-Authored-By: Claude Opus 5 (1M context) --- fileglancer/cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fileglancer/cli.py b/fileglancer/cli.py index d7605fdbe..9390324d8 100644 --- a/fileglancer/cli.py +++ b/fileglancer/cli.py @@ -1,4 +1,3 @@ -#leaned up CLI auto-login session """ Command-line interface for Fileglancer """