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/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 24c0102ac..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 @@ -139,6 +146,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/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 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..ea021abc2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-python-api-design.md @@ -0,0 +1,332 @@ +# 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`. + +--- + +## 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. 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/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/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/auth.py b/fileglancer/auth.py index 6cf3e101b..f9f0056f6 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: @@ -209,3 +218,162 @@ 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"), +) + +# Readable by any valid token regardless of scope: identity and liveness. +_ANY_SCOPE_PATHS = ("/api/profile",) + +_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 + + +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") + + # 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, + 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) + + # 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/cli.py b/fileglancer/cli.py index 6bff4efdb..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 """ @@ -159,8 +158,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/client.py b/fileglancer/client.py new file mode 100644 index 000000000..4d5e6e110 --- /dev/null +++ b/fileglancer/client.py @@ -0,0 +1,357 @@ +"""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 the FILEGLANCER_URL and FILEGLANCER_TOKEN env vars + link = fg.create_data_link("/data/alice/sample.zarr") +""" +import os +from typing import Any, Dict, List, Optional, Tuple + +import httpx + +from fileglancer.filestore import FileInfo +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 +# 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 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. + """ + + def __init__(self, url: Optional[str] = None, token: Optional[str] = None, + timeout: float = 60.0): + 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, + 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: + 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 .") + # 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}. " + f"Call file_share_paths() to see the available shares.") + + 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. + + 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.replace("\\", "/").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. + + 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: + """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"] + + # --- 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. + + 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. + """ + 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: + """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}") + + # --- 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, + 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 resources, name, env, or container. + """ + 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: + """Cancel a running or pending job.""" + self._request("POST", f"/api/jobs/{job_id}/cancel") diff --git a/fileglancer/database.py b/fileglancer/database.py index 1c861b084..1f1403704 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,97 @@ 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_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) + + row = ApiTokenDB( + token_id=token_id, + token_hash=hash_token_secret(secret), + username=username, + name=name, + scopes=" ".join(sorted(set(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/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/log.py b/fileglancer/log.py index ad8011428..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,11 +64,35 @@ 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 + # 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 + # 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" @@ -100,6 +111,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)}' @@ -114,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/model.py b/fileglancer/model.py index c7fa8b690..414ed656d 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -318,6 +318,72 @@ 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 + ) + + @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""" + 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 daf66ab15..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 @@ -42,13 +38,14 @@ 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 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 @@ -174,15 +171,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) @@ -216,6 +215,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\-._~/!@$&\'()*+,;:=%]+$') @@ -265,6 +269,36 @@ 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. + + 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=_ensure_utc(row.created_at), + expires_at=_ensure_utc(row.expires_at), + last_used_at=_ensure_utc(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. @@ -392,60 +426,97 @@ 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. + """ + 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 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). @@ -1309,6 +1380,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, @@ -1325,27 +1460,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( @@ -1445,6 +1579,75 @@ 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/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)): + 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))}") + + # 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, + 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, @@ -2575,19 +2778,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/fileglancer/settings.py b/fileglancer/settings.py index 73a3dfcb2..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 @@ -128,6 +131,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 +191,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/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 fa004c8fa..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 @@ -41,6 +38,8 @@ from loguru import logger +from fileglancer.logconf import InterceptHandler, configure_logging + # Length-prefix format: 4-byte big-endian unsigned int _HEADER_FMT = "!I" @@ -887,6 +886,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 @@ -1277,31 +1278,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..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 @@ -144,7 +141,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 +169,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 +462,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/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, + SCOPE_DESCRIPTIONS: actual.SCOPE_DESCRIPTIONS, + SCOPE_WARNINGS: actual.SCOPE_WARNINGS, + useApiTokensQuery: () => mockUseApiTokensQuery(), + useCreateApiTokenMutation: () => mockUseCreateApiTokenMutation(), + useDeleteApiTokenMutation: () => mockUseDeleteApiTokenMutation(), + useEnabledScopesQuery: () => mockUseEnabledScopesQuery() + }; +}); + +// 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:00', + expires_at: '2027-01-01T00:00:00', + 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() + }); +} + +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, + error: null, + reset: vi.fn() + }); + 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('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('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.getByText('List directories and read file contents') + ).toBeInTheDocument(); + // jobs:read reaches more than the name implies: full job detail and logs. + expect( + 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 () => { + 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('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('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(); + + 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: /new token/i })); + await user.type(screen.getByLabelText('Name'), 'new token'); + await user.click(screen.getByRole('button', { name: /^create$/i })); + + 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 () => { + 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/__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/components/ApiTokens.tsx b/frontend/src/components/ApiTokens.tsx new file mode 100644 index 000000000..206af4826 --- /dev/null +++ b/frontend/src/components/ApiTokens.tsx @@ -0,0 +1,153 @@ +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 RevokeTokenDialog from '@/components/ui/ApiTokens/RevokeTokenDialog'; +import FgExternalLink from '@/components/designSystem/atoms/FgExternalLink'; +import { Spinner } from '@/components/ui/widgets/Loaders'; +import { + useApiTokensQuery, + useDeleteApiTokenMutation +} 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 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; + + return ( + <> + + API Tokens + + + + API tokens let scripts and notebooks use Fileglancer through the{' '} + + 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 ? ( +
+ +
+ ) : 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 => ( + + ))} +
+
+ ) : null} + + + + setNewToken(null)} result={newToken} /> + + + + ); +} 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/ApiTokenCard.tsx b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx new file mode 100644 index 000000000..c45ffcbfc --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/ApiTokenCard.tsx @@ -0,0 +1,51 @@ +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'; + +export default function ApiTokenCard({ + token, + onRevoke, + isRevoking +}: { + readonly token: ApiTokenInfo; + readonly onRevoke: (token: ApiTokenInfo) => void; + readonly isRevoking: boolean; +}) { + const isExpired = new Date(token.expires_at) < new Date(); + + return ( + +
+
+ + {token.name} + {isExpired ? ( + + Expired + + ) : 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'} + +
+ onRevoke(token)} + 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..ad768843c --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/CreateTokenDialog.tsx @@ -0,0 +1,242 @@ +import { Fragment, 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'; +import FgFormField from '@/components/designSystem/molecules/FgFormField'; +import FgDialog from '@/components/ui/Dialogs/FgDialog'; +import { + API_SCOPES, + SCOPE_DESCRIPTIONS, + SCOPE_WARNINGS, + useCreateApiTokenMutation, + useEnabledScopesQuery +} from '@/queries/apiTokenQueries'; +import type { ApiScope, 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 enabledScopes = useEnabledScopesQuery(); + + // 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 = available.flatMap(scope => { + const warning = SCOPE_WARNINGS[scope]; + return warning && scopes.includes(scope) ? [{ scope, warning }] : []; + }); + + /** 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 = () => { + 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 () => { + try { + const result = await createToken.mutateAsync({ + name: name.trim(), + scopes: effectiveScopes, + 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 + + {/* 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. */} +
+ {available.map(scope => { + const locked = isImpliedByWrite(scope); + const descriptionId = `scope-${scope}-description`; + return ( + + toggleScope(scope)} + /> + + {SCOPE_DESCRIPTIONS[scope]} + + + ); + })} +
+ + {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 ? ( +
+ +
+ + 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} +
+ + + 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/ApiTokens/RevokeTokenDialog.tsx b/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx new file mode 100644 index 000000000..742f011c6 --- /dev/null +++ b/frontend/src/components/ui/ApiTokens/RevokeTokenDialog.tsx @@ -0,0 +1,58 @@ +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; + /** Message from a failed revoke, so a retry is not blind. */ + readonly error: string | null; + readonly onClose: () => void; + readonly onConfirm: () => void; +}; + +export default function RevokeTokenDialog({ + open, + tokenName, + isPending, + error, + 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. + + {error ? ( + + Could not revoke this token: {error} + + ) : null} + +
+ + Keep token + + + Revoke Token + +
+
+ ); +} 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 + = { + '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. + */ +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, + 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 + */ +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 }); + } + }); +} 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..1bbbf21fd --- /dev/null +++ b/frontend/ui-tests/tests/api-tokens.spec.ts @@ -0,0 +1,69 @@ +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(); + + // 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 + // 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(); + }); +}); diff --git a/frontend/ui-tests/utils/db-cleanup.ts b/frontend/ui-tests/utils/db-cleanup.ts index 22a80f64b..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']; + const tables = [ + 'proxied_paths', + 'tickets', + 'user_preferences', + 'api_tokens' + ]; for (const table of tables) { try { diff --git a/pixi.lock b/pixi.lock index 1025107b5..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: @@ -174,8 +173,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/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/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 @@ -342,8 +341,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/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/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 @@ -503,9 +502,9 @@ environments: - 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/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/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 @@ -667,173 +666,12 @@ environments: - 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/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/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 - 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/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/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 - - 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/ @@ -1050,9 +888,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/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/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 @@ -1267,9 +1105,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/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/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 @@ -1472,10 +1310,10 @@ environments: - 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/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 + - 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 @@ -1680,219 +1518,13 @@ environments: - 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/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/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 - 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/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/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 - - 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/ @@ -2147,8 +1779,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/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/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 @@ -2402,8 +2034,8 @@ environments: - 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/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/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 @@ -2653,9 +2285,9 @@ environments: - 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/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/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 @@ -2907,261 +2539,12 @@ 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/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.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/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 - 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/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/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 - - 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/ @@ -3418,8 +2801,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/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/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 +3059,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/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/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 +3311,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/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/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 @@ -4182,262 +3565,12 @@ 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/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/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 - 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/b0/6c/c25618e34b8ba7c26ef4f3f84df70e9e6c0fc8aa3806d04c1ab952051c8f/x2s3-1.3.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/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: @@ -15828,7 +13060,7 @@ packages: - python-jose==3.5.0 - sqlalchemy==2.0.51 - uvicorn==0.38.0 - - x2s3==1.3.0 + - 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' @@ -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 @@ -16243,10 +13434,30 @@ 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 +- 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 + sha256: fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e + requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl + name: hatch-nodejs-version + version: 0.3.2 + sha256: d73e728f1a262d214afe9c0a40e161013ef0b7a6c78ff843293880f6a46ede79 + requires_dist: + - hatchling>=0.21.0 + requires_python: '>=3.7' +- pypi: https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl + name: s3transfer + version: 0.19.2 + sha256: d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25 + requires_dist: + - 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/d0/2c/9990e39d2648610eb2b19d84aee00dac81677629fd739e62d0451e81d2b0/x2s3-1.5.2-py3-none-any.whl name: x2s3 - version: 1.3.0 - sha256: deb292575bd242004a903ba1bd1892d6528a91f501d348b932322fbf4f4131fe + version: 1.5.2 + sha256: 2165f06d8e262a5b14f04c68cba165f3c2451776cb52f5e602e39d6143bafc69 requires_dist: - aiobotocore>=2.22 - boto3>=1.37 @@ -16268,49 +13479,6 @@ packages: - 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 - sha256: fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e - requires_python: '>=3.10' -- pypi: https://files.pythonhosted.org/packages/b7/fe/b23e9bca77cafecd1a10450066a1a4ca329149ad36aa86cdf8e67c2d2fa5/hatch_nodejs_version-0.3.2-py3-none-any.whl - name: hatch-nodejs-version - version: 0.3.2 - sha256: d73e728f1a262d214afe9c0a40e161013ef0b7a6c78ff843293880f6a46ede79 - 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 - sha256: d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25 - requires_dist: - - 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/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl name: multidict version: 6.7.1 @@ -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 91ad8e44b..8740f63ba 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.2", "py-cluster-api ==0.8.0" ] @@ -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" } @@ -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.2,<2" py-cluster-api = ">=0.8.0,<0.9" build = ">=1.3.0,<2" diff --git a/scripts/api_walkthrough.py b/scripts/api_walkthrough.py new file mode 100644 index 000000000..b45f9109c --- /dev/null +++ b/scripts/api_walkthrough.py @@ -0,0 +1,384 @@ +#!/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: + 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: + # 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 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}") + 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="names the path; deliberately does not list every share") + + # --- 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) 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_api_scopes.py b/tests/test_api_scopes.py new file mode 100644 index 000000000..78ef27fc3 --- /dev/null +++ b/tests/test_api_scopes.py @@ -0,0 +1,121 @@ +"""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"), +]) +def test_required_scope_maps_path_and_method(path, method, expected): + assert required_scope(path, method) == expected + + +@pytest.mark.parametrize("path", ["/api/profile"]) +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 + + +# --- 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 new file mode 100644 index 000000000..ecae8696a --- /dev/null +++ b/tests/test_api_token_auth.py @@ -0,0 +1,337 @@ +"""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, + create_session, + dispose_engine, + sessionmaker, +) +from fileglancer.auth import API_SCOPES +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() + + # 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 + # 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"] + + +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_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. + + 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)}" + ) + + +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_api_token_endpoints.py b/tests/test_api_token_endpoints.py new file mode 100644 index 000000000..3f46389bb --- /dev/null +++ b/tests/test_api_token_endpoints.py @@ -0,0 +1,117 @@ +"""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 + + +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" + + +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_api_tokens.py b/tests/test_api_tokens.py new file mode 100644 index 000000000..37933b6e4 --- /dev/null +++ b/tests/test_api_tokens.py @@ -0,0 +1,155 @@ +"""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_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)} + 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 + + +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 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_files.py b/tests/test_client_files.py new file mode 100644 index 000000000..0accbf1b3 --- /dev/null +++ b/tests/test_client_files.py @@ -0,0 +1,147 @@ +"""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 pytest +from fastapi.testclient import TestClient + +from conftest import same_path +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, 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) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + 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 same_path(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_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")) + + 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) + client._client = TestClient( + app, headers={"Authorization": f"Bearer {plaintext}"}) + + 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") diff --git a/tests/test_client_jobs.py b/tests/test_client_jobs.py new file mode 100644 index 000000000..c88ebd52e --- /dev/null +++ b/tests/test_client_jobs.py @@ -0,0 +1,105 @@ +"""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 json + +import httpx +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): + # 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") + + 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"]) + 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() diff --git a/tests/test_client_links.py b/tests/test_client_links.py new file mode 100644 index 000000000..e60d19a92 --- /dev/null +++ b/tests/test_client_links.py @@ -0,0 +1,162 @@ +"""Client data-link and Neuroglancer-link operations.""" +import os + +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, + 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 same_path(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 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): + """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) + + fetched = fg.data_link(created.sharing_key) + + assert fetched.sharing_key == created.sharing_key + assert same_path(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() diff --git a/tests/test_client_paths.py b/tests/test_client_paths.py new file mode 100644 index 000000000..368f95568 --- /dev/null +++ b/tests/test_client_paths.py @@ -0,0 +1,190 @@ +"""Tests for client construction and absolute-path resolution. + +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 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_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 + + +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 "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): + 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_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'. + """ + 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") + + +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 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() diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 9eb855959..639f66737 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1919,3 +1919,154 @@ 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] + + +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" 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_log.py b/tests/test_log.py index d6a46e632..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 -from fastapi import FastAPI +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() @@ -86,3 +100,122 @@ 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] + + +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..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, @@ -95,6 +89,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 +138,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):