diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 632537387..18c6ac314 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -80,9 +80,6 @@ jobs: git reset --hard HEAD git clean -fd # For sdist, ensure local runtime binaries are not packaged even if present - rm -rf openviking/bin openviking/lib third_party/agfs/bin || true - rm -f openviking/storage/vectordb/*.so openviking/storage/vectordb/*.dylib openviking/storage/vectordb/*.dll openviking/storage/vectordb/*.exe || true - rm -rf openviking/_version.py openviking.egg-info # Ignore uv.lock changes to avoid dirty state in setuptools_scm git update-index --assume-unchanged uv.lock || true @@ -193,11 +190,6 @@ jobs: echo "LD_LIBRARY_PATH=${PYTHON_PREFIX}/lib:${LD_LIBRARY_PATH}" >> "$GITHUB_ENV" export LD_LIBRARY_PATH="${PYTHON_PREFIX}/lib:${LD_LIBRARY_PATH}" "$PYTHON_BIN" -V - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.1' - - name: Set up Rust uses: dtolnay/rust-toolchain@v1 with: @@ -351,11 +343,6 @@ jobs: echo "_PYTHON_HOST_PLATFORM=macosx-${MACOS_VERSION}-${TARGET_ARCH}" >> "$GITHUB_ENV" echo "Configured macOS wheel platform: macosx-${MACOS_VERSION}-${TARGET_ARCH}" - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.1' - - name: Set up Rust uses: dtolnay/rust-toolchain@v1 with: diff --git a/.github/workflows/_codeql.yml b/.github/workflows/_codeql.yml index ca007e316..646c97aa1 100644 --- a/.github/workflows/_codeql.yml +++ b/.github/workflows/_codeql.yml @@ -29,11 +29,6 @@ jobs: with: python-version: '3.11' - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: 'stable' - - name: Install uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 3cbeec148..9bbde04dd 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -19,11 +19,6 @@ jobs: with: python-version: '3.11' - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: 'stable' - - name: Install uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/_test_full.yml b/.github/workflows/_test_full.yml index 30a58c9ec..4ea21488d 100644 --- a/.github/workflows/_test_full.yml +++ b/.github/workflows/_test_full.yml @@ -44,11 +44,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.1' - - name: Install uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/_test_lite.yml b/.github/workflows/_test_lite.yml index 52e6a7097..2374f35f3 100644 --- a/.github/workflows/_test_lite.yml +++ b/.github/workflows/_test_lite.yml @@ -44,11 +44,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.1' - - name: Install uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/api_test.yml b/.github/workflows/api_test.yml index 45d99a579..bc90ff58f 100644 --- a/.github/workflows/api_test.yml +++ b/.github/workflows/api_test.yml @@ -59,15 +59,6 @@ jobs: with: python-version: '3.10' - - name: Cache Go modules - uses: actions/cache@v5 - continue-on-error: true - with: - path: ~/go/pkg/mod - key: ${{ runner.os }}-go-${{ hashFiles('third_party/agfs/**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Cache Python dependencies (Unix) if: runner.os != 'Windows' uses: actions/cache@v5 @@ -86,11 +77,6 @@ jobs: restore-keys: | ${{ runner.os }}-pip- - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: '1.25.1' - - name: Install system dependencies (Ubuntu) if: runner.os == 'Linux' run: | diff --git a/Dockerfile b/Dockerfile index 3a9cecd63..3515dc84b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,17 @@ # syntax=docker/dockerfile:1.9 -# Stage 1: provide Go toolchain (required by setup.py -> build_agfs_artifacts -> make build) -FROM golang:1.26-trixie AS go-toolchain - -# Stage 2: provide Rust toolchain (required by setup.py -> build_ov_cli_artifact -> cargo build) +# Stage 1: provide Rust toolchain (required by setup.py -> build_ov_cli_artifact -> cargo build) FROM rust:1.88-trixie AS rust-toolchain -# Stage 3: build Python environment with uv (builds AGFS + Rust CLI + C++ extension from source) +# Stage 2: build Python environment with uv (builds Rust CLI + C++ extension from source) FROM ghcr.io/astral-sh/uv:python3.13-trixie-slim AS py-builder -# Reuse Go toolchain from stage 1 so setup.py can compile agfs-server in-place. -COPY --from=go-toolchain /usr/local/go /usr/local/go -# Reuse Rust toolchain from stage 2 so setup.py can compile ov CLI in-place. +# Reuse Rust toolchain from stage 1 so setup.py can compile ov CLI in-place. COPY --from=rust-toolchain /usr/local/cargo /usr/local/cargo COPY --from=rust-toolchain /usr/local/rustup /usr/local/rustup ENV CARGO_HOME=/usr/local/cargo ENV RUSTUP_HOME=/usr/local/rustup -ENV PATH="/app/.venv/bin:/usr/local/cargo/bin:/usr/local/go/bin:${PATH}" +ENV PATH="/app/.venv/bin:/usr/local/cargo/bin:${PATH}" ARG OPENVIKING_VERSION=0.0.0 ARG TARGETPLATFORM ARG UV_LOCK_STRATEGY=auto @@ -42,7 +37,6 @@ COPY crates/ crates/ COPY openviking/ openviking/ COPY openviking_cli/ openviking_cli/ COPY src/ src/ -COPY third_party/ third_party/ # Install project and dependencies (triggers setup.py artifact builds + build_extension). # Default to auto-refreshing uv.lock inside the ephemeral build context when it is @@ -65,9 +59,8 @@ RUN --mount=type=cache,target=/root/.cache/uv,id=uv-${TARGETPLATFORM} \ ;; \ esac -# Build ragfs-python (Rust AGFS binding) and extract the native extension -# into the installed openviking package so it ships alongside the Go binding. -# Selection at runtime via RAGFS_IMPL env var (auto/rust/go). +# Build ragfs-python (Rust RAGFS binding) and extract the native extension +# into the installed openviking package. RUN --mount=type=cache,target=/root/.cache/uv,id=uv-${TARGETPLATFORM} \ uv pip install maturin && \ export _TMPDIR=$(mktemp -d) && \ @@ -103,7 +96,7 @@ print("WARNING: No ragfs_python .so/.pyd in wheel") sys.exit(1) PY -# Stage 4: runtime +# Stage 3: runtime FROM python:3.13-slim-trixie RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/MANIFEST.in b/MANIFEST.in index e69ccc18a..d93d175a5 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,9 +3,6 @@ graft third_party/leveldb-1.23 graft third_party/spdlog-1.14.1 graft third_party/croaring graft third_party/rapidjson -recursive-include third_party/agfs/agfs-server *.go go.mod go.sum Makefile -recursive-include third_party/agfs/agfs-sdk/go *.go go.mod -include third_party/agfs/bin/agfs-server include LICENSE include README.md include pyproject.toml diff --git a/Makefile b/Makefile index a02586393..f736e67b5 100644 --- a/Makefile +++ b/Makefile @@ -3,12 +3,10 @@ # Variables PYTHON ?= python3 SETUP_PY := setup.py -AGFS_SERVER_DIR := third_party/agfs/agfs-server OV_CLI_DIR := crates/ov_cli # Dependency Versions MIN_PYTHON_VERSION := 3.10 -MIN_GO_VERSION := 1.22 MIN_CMAKE_VERSION := 3.12 MIN_RUST_VERSION := 1.88 MIN_GCC_VERSION := 9 @@ -21,7 +19,6 @@ CLEAN_DIRS := \ *.egg-info/ \ openviking/bin/ \ openviking/lib/ \ - $(AGFS_SERVER_DIR)/build/ \ $(OV_CLI_DIR)/target/ \ src/cmake_build/ \ .pytest_cache/ \ @@ -35,9 +32,9 @@ all: build help: @echo "Available targets:" - @echo " build - Build AGFS, ov CLI, and C++ extensions using setup.py" + @echo " build - Build ragfs-python and C++ extensions using setup.py" @echo " clean - Remove build artifacts and temporary files" - @echo " check-deps - Check if required dependencies (Go, Rust, CMake, etc.) are installed" + @echo " check-deps - Check if required dependencies (Rust, CMake, etc.) are installed" @echo " help - Show this help message" check-pip: @@ -59,11 +56,6 @@ check-deps: @# Python check @$(PYTHON) -c "import sys; v=sys.version_info; exit(0 if v.major > 3 or (v.major == 3 and v.minor >= 10) else 1)" || (echo "Error: Python >= $(MIN_PYTHON_VERSION) is required."; exit 1) @echo " [OK] Python $$( $(PYTHON) -V | cut -d' ' -f2 )" - @# Go check - @command -v go > /dev/null 2>&1 || (echo "Error: Go is not installed."; exit 1) - @GO_VER=$$(go version | awk '{print $$3}' | sed 's/go//'); \ - $(PYTHON) -c "v='$$GO_VER'.split('.'); exit(0 if int(v[0]) > 1 or (int(v[0]) == 1 and int(v[1]) >= 22) else 1)" || (echo "Error: Go >= $(MIN_GO_VERSION) is required. Found $$GO_VER"; exit 1); \ - echo " [OK] Go $$GO_VER" @# CMake check @command -v cmake > /dev/null 2>&1 || (echo "Error: CMake is not installed."; exit 1) @CMAKE_VER=$$(cmake --version | head -n1 | awk '{print $$3}'); \ @@ -99,7 +91,7 @@ build: check-deps check-pip echo " [OK] pip found, use pip to install..."; \ $(PYTHON) -m pip install -e .; \ fi - @echo "Building ragfs-python (Rust AGFS binding) into openviking/lib/..." + @echo "Building ragfs-python (Rust RAGFS binding) into openviking/lib/..." @MATURIN_CMD=""; \ if command -v maturin > /dev/null 2>&1; then \ MATURIN_CMD=maturin; \ @@ -131,7 +123,6 @@ build: check-deps check-pip else \ echo " [SKIP] maturin not found, ragfs-python (Rust binding) will not be built."; \ echo " Install maturin to enable: uv pip install maturin"; \ - echo " The Go binding will be used as fallback."; \ fi @echo "Build completed successfully." @@ -145,4 +136,4 @@ clean: done @find . -name "*.pyc" -delete @find . -name "__pycache__" -type d -exec rm -rf {} + - @echo "Cleanup completed." + @echo "Cleanup completed." \ No newline at end of file diff --git a/benchmark/RAG/ov.conf.example b/benchmark/RAG/ov.conf.example index e41a79d9a..9ea5f47e2 100644 --- a/benchmark/RAG/ov.conf.example +++ b/benchmark/RAG/ov.conf.example @@ -1,7 +1,6 @@ { "storage": { "agfs": { - "port": 1876 } }, "log": { diff --git a/crates/ragfs/src/plugins/s3fs/client.rs b/crates/ragfs/src/plugins/s3fs/client.rs index 8a60ed54d..a89e67fb3 100644 --- a/crates/ragfs/src/plugins/s3fs/client.rs +++ b/crates/ragfs/src/plugins/s3fs/client.rs @@ -79,6 +79,7 @@ pub struct S3Client { bucket: String, prefix: String, marker_mode: DirectoryMarkerMode, + disable_batch_delete: bool, } impl S3Client { @@ -125,6 +126,11 @@ impl S3Client { .map(|s| DirectoryMarkerMode::from_str(s)) .unwrap_or(DirectoryMarkerMode::Empty); + let disable_batch_delete = config + .get("disable_batch_delete") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + // Build S3 config let mut s3_config_builder = aws_sdk_s3::Config::builder() .behavior_version(BehaviorVersion::latest()) @@ -150,6 +156,7 @@ impl S3Client { bucket, prefix, marker_mode, + disable_batch_delete, }) } @@ -258,35 +265,51 @@ impl S3Client { } /// Batch delete objects (up to 1000 per call) + /// If disable_batch_delete is true, use sequential single-object deletes + /// for S3-compatible services (e.g., Alibaba Cloud OSS) that require + /// Content-MD5 for DeleteObjects but AWS SDK v2 does not send it by default. pub async fn delete_objects(&self, keys: &[String]) -> Result<()> { if keys.is_empty() { return Ok(()); } - // S3 batch delete limit is 1000 - for chunk in keys.chunks(1000) { - let objects: Vec<_> = chunk - .iter() - .map(|k| { - aws_sdk_s3::types::ObjectIdentifier::builder() - .key(k.as_str()) - .build() - .unwrap() - }) - .collect(); - - let delete = aws_sdk_s3::types::Delete::builder() - .set_objects(Some(objects)) - .build() - .map_err(|e| Error::internal(format!("S3 build delete: {}", e)))?; - - self.client - .delete_objects() - .bucket(&self.bucket) - .delete(delete) - .send() - .await - .map_err(|e| Error::internal(format!("S3 DeleteObjects error: {}", e)))?; + if self.disable_batch_delete { + // Sequential single-object delete + for key in keys { + self.client + .delete_object() + .bucket(&self.bucket) + .key(key.as_str()) + .send() + .await + .map_err(|e| Error::internal(format!("S3 DeleteObject error: {}", e)))?; + } + } else { + // S3 batch delete limit is 1000 + for chunk in keys.chunks(1000) { + let objects: Vec<_> = chunk + .iter() + .map(|k| { + aws_sdk_s3::types::ObjectIdentifier::builder() + .key(k.as_str()) + .build() + .unwrap() + }) + .collect(); + + let delete = aws_sdk_s3::types::Delete::builder() + .set_objects(Some(objects)) + .build() + .map_err(|e| Error::internal(format!("S3 build delete: {}", e)))?; + + self.client + .delete_objects() + .bucket(&self.bucket) + .delete(delete) + .send() + .await + .map_err(|e| Error::internal(format!("S3 DeleteObjects error: {}", e)))?; + } } Ok(()) diff --git a/crates/ragfs/src/plugins/s3fs/mod.rs b/crates/ragfs/src/plugins/s3fs/mod.rs index 0fdc070bb..cff095919 100644 --- a/crates/ragfs/src/plugins/s3fs/mod.rs +++ b/crates/ragfs/src/plugins/s3fs/mod.rs @@ -532,6 +532,12 @@ impl S3FSPlugin { "empty", "Directory marker mode: none, empty, nonempty", ), + ConfigParameter::optional( + "disable_batch_delete", + "bool", + "false", + "Disable batch delete (DeleteObjects) for S3-compatible services like OSS", + ), ConfigParameter::optional( "cache_enabled", "bool", @@ -636,6 +642,19 @@ plugins: directory_marker_mode: nonempty ``` +### Alibaba Cloud OSS +```yaml +plugins: + s3fs: + enabled: true + path: /s3 + config: + bucket: my-oss-bucket + region: cn-beijing + endpoint: http://s3.oss-cn-beijing.aliyuncs.com + disable_batch_delete: true +``` + ## Directory Marker Modes - `empty` (default): Zero-byte marker objects for directories diff --git a/deploy/helm/openviking/values.yaml b/deploy/helm/openviking/values.yaml index 08e232833..3d5dd9535 100644 --- a/deploy/helm/openviking/values.yaml +++ b/deploy/helm/openviking/values.yaml @@ -80,11 +80,8 @@ config: backend: local project: default agfs: - port: 1833 - log_level: warn backend: local timeout: 10 - retry_times: 3 log: level: INFO output: stdout diff --git a/docs/en/concepts/05-storage.md b/docs/en/concepts/05-storage.md index 0c14986b7..97379adfa 100644 --- a/docs/en/concepts/05-storage.md +++ b/docs/en/concepts/05-storage.md @@ -32,6 +32,7 @@ OpenViking uses a dual-layer storage architecture that separates content storage 2. **Memory optimization**: Vector index doesn't store file content, saving memory 3. **Single data source**: All content read from AGFS; vector index only stores references 4. **Independent scaling**: Vector index and AGFS can scale separately +Note: AGFS has been rewritten as a Rust implementation (RAGFS) ## VikingFS Virtual Filesystem diff --git a/docs/en/guides/01-configuration.md b/docs/en/guides/01-configuration.md index 943c70539..da4afc629 100644 --- a/docs/en/guides/01-configuration.md +++ b/docs/en/guides/01-configuration.md @@ -15,8 +15,6 @@ Create `~/.openviking/ov.conf` in your project directory: "backend": "local" }, "agfs": { - "port": 1833, - "log_level": "warn", "backend": "local" } }, @@ -579,14 +577,14 @@ If rerank is not configured, search uses vector similarity only. ### storage -Storage configuration for context data, including file storage (AGFS) and vector database storage (VectorDB). +Storage configuration for context data, including file storage (RAGFS) and vector database storage (VectorDB). #### Root Configuration | Parameter | Type | Description | Default | |-----------|------|-------------|---------| | `workspace` | str | Local data storage path (main configuration) | "./data" | -| `agfs` | object | AGFS configuration | {} | +| `agfs` | object | RAGFS (Rust-based AGFS) configuration | {} | | `vectordb` | object | Vector database storage configuration | {} | @@ -605,55 +603,17 @@ Storage configuration for context data, including file storage (AGFS) and vector } ``` -#### agfs +#### agfs (RAGFS) | Parameter | Type | Description | Default | |-----------|------|-------------|---------| -| `mode` | str | `"http-client"` or `"binding-client"` | `"http-client"` | | `backend` | str | `"local"`, `"s3"`, or `"memory"` | `"local"` | -| `url` | str | AGFS service URL for `http-client` mode | `"http://localhost:1833"` | | `timeout` | float | Request timeout in seconds | `10.0` | | `s3` | object | S3 backend configuration (when backend is 's3') | - | **Configuration Examples** -
-HTTP Client (Default) - -Connects to a remote or local AGFS service via HTTP. - -```json -{ - "storage": { - "agfs": { - "mode": "http-client", - "url": "http://localhost:1833", - "timeout": 10.0 - } - } -} -``` - -
- -
-Binding Client (High Performance) - -Directly uses the AGFS Go implementation through a shared library. - -**Config**: -```json -{ - "storage": { - "agfs": { - "mode": "binding-client", - "backend": "local" - } - } -} -``` - -
+RAGFS uses Rust binding mode by default, directly accessing the file system through the Rust implementation. ##### S3 Backend Configuration @@ -670,11 +630,11 @@ Directly uses the AGFS Go implementation through a shared library. | `use_path_style` | bool | true for PathStyle used by MinIO and some S3-compatible services; false for VirtualHostStyle used by TOS and some S3-compatible services | true | | `directory_marker_mode` | str | How to persist directory markers: `none`, `empty`, or `nonempty` | `"empty"` | -`directory_marker_mode` controls how AGFS materializes directory objects in S3: +`directory_marker_mode` controls how RAGFS materializes directory objects in S3: -- `empty` is the default. AGFS writes a zero-byte directory marker and preserves empty-directory semantics. +- `empty` is the default. RAGFS writes a zero-byte directory marker and preserves empty-directory semantics. - `nonempty` writes a non-empty marker payload. Use this for S3-compatible services such as TOS that reject zero-byte directory markers. -- `none` switches AGFS to prefix-style S3 semantics. AGFS does not create directory marker objects, so empty directories are not persisted and may not be discoverable until they contain at least one child object. +- `none` switches RAGFS to prefix-style S3 semantics. RAGFS does not create directory marker objects, so empty directories are not persisted and may not be discoverable until they contain at least one child object. Typical choices: @@ -1055,7 +1015,6 @@ For detailed encryption explanations, see [Data Encryption](../concepts/10-encry "workspace": "string", "agfs": { "backend": "local|s3|memory", - "url": "string", "timeout": 10 }, "transaction": { diff --git a/docs/en/guides/03-deployment.md b/docs/en/guides/03-deployment.md index 427c964be..488d7fd34 100644 --- a/docs/en/guides/03-deployment.md +++ b/docs/en/guides/03-deployment.md @@ -63,7 +63,7 @@ The `server` section in `ov.conf` controls server behavior: ### Standalone (Embedded Storage) -Server manages local AGFS and VectorDB. Configure the storage path in `ov.conf`: +Server manages local RAGFS and VectorDB. Configure the storage path in `ov.conf`: ```json { @@ -79,23 +79,6 @@ Server manages local AGFS and VectorDB. Configure the storage path in `ov.conf`: openviking-server ``` -### Hybrid (Remote Storage) - -Server connects to remote AGFS and VectorDB services. Configure remote URLs in `ov.conf`: - -```json -{ - "storage": { - "agfs": { "backend": "remote", "url": "http://agfs:1833" }, - "vectordb": { "backend": "remote", "url": "http://vectordb:8000" } - } -} -``` - -```bash -openviking-server -``` - ## Deploying with Systemd (Recommended) For Linux systems, you can use Systemd to manage OpenViking as a service, enabling automatic restart and startup on boot. Firstly, you should tried to install and configure openviking on your own. diff --git a/docs/zh/concepts/05-storage.md b/docs/zh/concepts/05-storage.md index 495bdc387..297c13002 100644 --- a/docs/zh/concepts/05-storage.md +++ b/docs/zh/concepts/05-storage.md @@ -30,6 +30,7 @@ OpenViking 采用双层存储架构,分离内容存储和索引存储。 2. **内存优化**:向量库不存储文件内容,节省内存 3. **单一数据源**:所有内容从 AGFS 读取,向量库只存引用 4. **独立扩展**:向量库和 AGFS 可分别扩展 +> 注:AGFS 已经重写为 Rust 实现(RAGFS) ## VikingFS 虚拟文件系统 diff --git a/docs/zh/guides/01-configuration.md b/docs/zh/guides/01-configuration.md index e7a8adb4b..15c563d2c 100644 --- a/docs/zh/guides/01-configuration.md +++ b/docs/zh/guides/01-configuration.md @@ -15,8 +15,6 @@ OpenViking 使用 JSON 配置文件(`ov.conf`)进行设置。配置文件支 "backend": "local" }, "agfs": { - "port": 1833, - "log_level": "warn", "backend": "local" } }, @@ -550,14 +548,14 @@ AST 提取支持:Python、JavaScript/TypeScript、Rust、Go、Java、C/C++。 ### storage -用于存储上下文数据 ,包括文件存储(AGFS)和向量库存储(VectorDB)。 +用于存储上下文数据 ,包括文件存储(RAGFS)和向量库存储(VectorDB)。 #### 根级配置 | 参数 | 类型 | 说明 | 默认值 | |------|------|------|--------| | `workspace` | str | 本地数据存储路径(主要配置) | "./data" | -| `agfs` | object | agfs 配置 | {} | +| `agfs` | object | RAGFS(Rust 实现的 AGFS)配置 | {} | | `vectordb` | object | 向量库存储配置 | {} | @@ -576,56 +574,18 @@ AST 提取支持:Python、JavaScript/TypeScript、Rust、Go、Java、C/C++。 } ``` -#### agfs +#### agfs (RAGFS) | 参数 | 类型 | 说明 | 默认值 | |------|------|------|--------| -| `mode` | str | `"http-client"` 或 `"binding-client"` | `"http-client"` | | `backend` | str | `"local"`、`"s3"` 或 `"memory"` | `"local"` | -| `url` | str | `http-client` 模式下的 AGFS 服务地址 | `"http://localhost:1833"` | | `timeout` | float | 请求超时时间(秒) | `10.0` | | `s3` | object | S3 backend configuration (when backend is 's3') | - | **配置示例** -
-HTTP Client(默认) - -通过 HTTP 连接到远程或本地的 AGFS 服务。 - -```json -{ - "storage": { - "agfs": { - "mode": "http-client", - "url": "http://localhost:1833", - "timeout": 10.0 - } - } -} -``` - -
- -
-Binding Client(高性能) - -通过共享库直接使用 AGFS 的 Go 实现。 - -**配置**: -```json -{ - "storage": { - "agfs": { - "mode": "binding-client", - "backend": "local" - } - } -} -``` - -
+RAGFS 默认使用 Rust binding 模式,通过 Rust 实现直接访问文件系统。 ##### S3 后端配置 @@ -642,11 +602,11 @@ AST 提取支持:Python、JavaScript/TypeScript、Rust、Go、Java、C/C++。 | `use_path_style` | bool | true 表示对 MinIO 和某些 S3 兼容服务使用 PathStyle;false 表示对 TOS 和某些 S3 兼容服务使用 VirtualHostStyle | true | | `directory_marker_mode` | str | 目录 marker 的持久化方式,可选 `none`、`empty`、`nonempty` | `"empty"` | -`directory_marker_mode` 用来控制 AGFS 在 S3 中如何落目录对象: +`directory_marker_mode` 用来控制 RAGFS 在 S3 中如何落目录对象: -- `empty` 是默认值。AGFS 会写入 0 字节目录 marker,并保留空目录语义。 +- `empty` 是默认值。RAGFS 会写入 0 字节目录 marker,并保留空目录语义。 - `nonempty` 会写入非空目录 marker。对于 TOS 这类拒绝 0 字节目录 marker 的 S3 兼容后端,应使用这个模式。 -- `none` 会让 AGFS 采用更接近原生 S3 prefix 的目录语义,不再创建目录 marker 对象。此时空目录不会被持久化,只有目录下至少存在一个子对象后,相关目录才可能被发现。 +- `none` 会让 RAGFS 采用更接近原生 S3 prefix 的目录语义,不再创建目录 marker 对象。此时空目录不会被持久化,只有目录下至少存在一个子对象后,相关目录才可能被发现。 典型选择: @@ -1028,7 +988,6 @@ openviking --account acme --user alice --agent-id assistant-2 ls viking:// "workspace": "string", "agfs": { "backend": "local|s3|memory", - "url": "string", "timeout": 10 }, "transaction": { diff --git a/docs/zh/guides/03-deployment.md b/docs/zh/guides/03-deployment.md index 6514a40ca..d5f7fbf27 100644 --- a/docs/zh/guides/03-deployment.md +++ b/docs/zh/guides/03-deployment.md @@ -63,7 +63,7 @@ openviking-server --config /path/to/ov.conf --host 127.0.0.1 --port 8000 ### 独立模式(嵌入存储) -服务器管理本地 AGFS 和 VectorDB。在 `ov.conf` 中配置本地存储路径: +服务器管理本地 RAGFS 和 VectorDB。在 `ov.conf` 中配置本地存储路径: ```json { @@ -79,23 +79,6 @@ openviking-server --config /path/to/ov.conf --host 127.0.0.1 --port 8000 openviking-server ``` -### 混合模式(远程存储) - -服务器连接到远程 AGFS 和 VectorDB 服务。在 `ov.conf` 中配置远程地址: - -```json -{ - "storage": { - "agfs": { "backend": "remote", "url": "http://agfs:1833" }, - "vectordb": { "backend": "remote", "url": "http://vectordb:8000" } - } -} -``` - -```bash -openviking-server -``` - ## 使用 Systemd 部署服务(推荐) 对于 Linux 系统,可以使用 Systemd 服务来管理 OpenViking,实现自动重启、开机自启等功能。首先,你应该已经成功安装并配置了 OpenViking 服务器,确保它可以正常运行,再进行服务化部署。 diff --git a/examples/claude-code-memory-plugin/README.md b/examples/claude-code-memory-plugin/README.md index 5f0b87191..974dca493 100644 --- a/examples/claude-code-memory-plugin/README.md +++ b/examples/claude-code-memory-plugin/README.md @@ -133,7 +133,7 @@ vim ~/.openviking/ov.conf "storage": { "workspace": "/home/yourname/.openviking/data", "vectordb": { "backend": "local" }, - "agfs": { "backend": "local", "port": 1833 } + "agfs": { "backend": "local" } }, "embedding": { "dense": { diff --git a/examples/claude-code-memory-plugin/README_CN.md b/examples/claude-code-memory-plugin/README_CN.md index 6fef5cf95..7793cd14b 100644 --- a/examples/claude-code-memory-plugin/README_CN.md +++ b/examples/claude-code-memory-plugin/README_CN.md @@ -130,7 +130,7 @@ vim ~/.openviking/ov.conf "storage": { "workspace": "/home/yourname/.openviking/data", "vectordb": { "backend": "local" }, - "agfs": { "backend": "local", "port": 1833 } + "agfs": { "backend": "local" } }, "embedding": { "dense": { diff --git a/examples/cloud/GUIDE.md b/examples/cloud/GUIDE.md index b3678ec30..df48a5626 100644 --- a/examples/cloud/GUIDE.md +++ b/examples/cloud/GUIDE.md @@ -320,11 +320,8 @@ openviking: ak: "AKLTxxxxxxxxxxxx" sk: "T1dYxxxxxxxxxxxx" agfs: - port: 1833 - log_level: warn backend: s3 timeout: 10 - retry_times: 3 s3: bucket: "openvikingdata" region: cn-beijing diff --git a/examples/cloud/ov.conf.example b/examples/cloud/ov.conf.example index a7cef4178..97ff69792 100644 --- a/examples/cloud/ov.conf.example +++ b/examples/cloud/ov.conf.example @@ -18,11 +18,8 @@ } }, "agfs": { - "port": 1833, - "log_level": "warn", "backend": "s3", "timeout": 10, - "retry_times": 3, "s3": { "bucket": "", "region": "cn-beijing", diff --git a/examples/multi_tenant/ov.conf.example b/examples/multi_tenant/ov.conf.example index 4f2a3e009..0ed595599 100644 --- a/examples/multi_tenant/ov.conf.example +++ b/examples/multi_tenant/ov.conf.example @@ -12,8 +12,6 @@ "path": "./data" }, "agfs": { - "port": 1833, - "log_level": "warn", "path": "./data", "backend": "local" } diff --git a/examples/openclaw-plugin/install.sh b/examples/openclaw-plugin/install.sh index 8f7931f85..f93378fa6 100755 --- a/examples/openclaw-plugin/install.sh +++ b/examples/openclaw-plugin/install.sh @@ -70,7 +70,6 @@ OPENCLAW_DIR="${DEFAULT_OPENCLAW_DIR}" OPENVIKING_DIR="${HOME_DIR}/.openviking" PLUGIN_DEST="" # Will be set after resolving plugin config DEFAULT_SERVER_PORT=1933 -DEFAULT_AGFS_PORT=1833 DEFAULT_VLM_MODEL="doubao-seed-2-0-pro-260215" DEFAULT_EMBED_MODEL="doubao-embedding-vision-251215" SELECTED_SERVER_PORT="${DEFAULT_SERVER_PORT}" @@ -1662,7 +1661,6 @@ configure_openviking_conf() { local workspace="${OPENVIKING_DIR}/data" local server_port="${DEFAULT_SERVER_PORT}" - local agfs_port="${DEFAULT_AGFS_PORT}" local vlm_model="${DEFAULT_VLM_MODEL}" local embedding_model="${DEFAULT_EMBED_MODEL}" local vlm_api_key="${OPENVIKING_VLM_API_KEY:-${OPENVIKING_ARK_API_KEY:-}}" @@ -1681,7 +1679,6 @@ configure_openviking_conf() { echo "" read -r -p "$(tr "OpenViking workspace path [${workspace}]: " "OpenViking 数据目录 [${workspace}]: ")" _workspace < /dev/tty || true read -r -p "OpenViking HTTP port [${server_port}]: " _server_port < /dev/tty || true - read -r -p "AGFS port [${agfs_port}]: " _agfs_port < /dev/tty || true read -r -p "VLM model [${vlm_model}]: " _vlm_model < /dev/tty || true read -r -p "Embedding model [${embedding_model}]: " _embedding_model < /dev/tty || true echo "VLM and Embedding API keys can differ. You can leave either empty and edit ov.conf later." @@ -1690,7 +1687,6 @@ configure_openviking_conf() { workspace="${_workspace:-$workspace}" server_port="${_server_port:-$server_port}" - agfs_port="${_agfs_port:-$agfs_port}" vlm_model="${_vlm_model:-$vlm_model}" embedding_model="${_embedding_model:-$embedding_model}" vlm_api_key="${_vlm_api_key:-$vlm_api_key}" @@ -1698,14 +1694,12 @@ configure_openviking_conf() { fi server_port="$(normalize_port "${server_port}" "${DEFAULT_SERVER_PORT}" "OpenViking HTTP port")" - agfs_port="$(normalize_port "${agfs_port}" "${DEFAULT_AGFS_PORT}" "AGFS port")" mkdir -p "${workspace}" local py_json="${OPENVIKING_PYTHON_PATH:-${OPENVIKING_PYTHON:-}}" [[ -z "$py_json" ]] && py_json="$(command -v python3 || command -v python || true)" [[ -z "$py_json" ]] && py_json="python3" WORKSPACE="${workspace}" \ SERVER_PORT="${server_port}" \ - AGFS_PORT="${agfs_port}" \ VLM_MODEL="${vlm_model}" \ EMBEDDING_MODEL="${embedding_model}" \ VLM_API_KEY="${vlm_api_key}" \ @@ -1729,11 +1723,8 @@ config = { "workspace": os.environ["WORKSPACE"], "vectordb": {"name": "context", "backend": "local", "project": "default"}, "agfs": { - "port": int(os.environ["AGFS_PORT"]), - "log_level": "warn", "backend": "local", "timeout": 10, - "retry_times": 3, }, }, "embedding": { diff --git a/examples/ov.conf.example b/examples/ov.conf.example index 1940bf274..456c995d5 100644 --- a/examples/ov.conf.example +++ b/examples/ov.conf.example @@ -19,11 +19,8 @@ } }, "agfs": { - "port": 1833, - "log_level": "warn", "backend": "local", "timeout": 10, - "retry_times": 3, "s3": { "bucket": null, "region": null, @@ -31,7 +28,8 @@ "secret_key": null, "endpoint": null, "prefix": "", - "use_ssl": true + "use_ssl": true, + "disable_batch_delete": false } } }, diff --git a/openviking/agfs_manager.py b/openviking/agfs_manager.py deleted file mode 100644 index cbb59e1f7..000000000 --- a/openviking/agfs_manager.py +++ /dev/null @@ -1,289 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 -"""AGFS Process Manager - Responsible for starting and stopping the AGFS server.""" - -import atexit -import platform -import socket -import subprocess -import time -from pathlib import Path -from typing import TYPE_CHECKING, Optional - -import yaml - -from openviking_cli.utils import get_logger - -if TYPE_CHECKING: - from openviking_cli.utils.config.agfs_config import AGFSConfig - -logger = get_logger(__name__) - - -class AGFSManager: - """ - Manages the lifecycle of the AGFS server process. - - Examples: - # 1. Local backend - from openviking_cli.utils.config.agfs_config import AGFSConfig - - config = AGFSConfig( - path="./data", - port=1833, - backend="local", - log_level="info" - ) - manager = AGFSManager(config=config) - manager.start() - - # 2. S3 backend - from openviking_cli.utils.config.agfs_config import AGFSConfig, S3Config - - config = AGFSConfig( - path="./data", - port=1833, - backend="s3", - s3=S3Config( - bucket="my-bucket", - region="us-east-1", - access_key="your-access-key", - secret_key="your-secret-key", - endpoint="https://s3.amazonaws.com" - ), - log_level="debug" - ) - manager = AGFSManager(config=config) - manager.start() - - # 3. Using with context manager (auto cleanup) - with AGFSManager(config=config): - # AGFS server is running - pass - # Server automatically stopped - """ - - def __init__( - self, - config: "AGFSConfig", - ): - """ - Initialize AGFS Manager. - - Args: - config: AGFS configuration object containing settings like port, path, backend, etc. - """ - self.data_path = Path(config.path).resolve() # Convert to absolute path - self.config = config - self.port = config.port - self.log_level = config.log_level - self.backend = config.backend - self.s3_config = config.s3 - - self.process: Optional[subprocess.Popen] = None - self.config_file: Optional[Path] = None - - atexit.register(self.stop) - - @property - def vikingfs_path(self) -> Path: - """AGFS LocalFS data directory.""" - return self.data_path / "viking" - - @property - def binary_path(self) -> Path: - """AGFS binary file path.""" - package_dir = Path(__file__).parent - binary_name = "agfs-server" - if platform.system() == "Windows": - binary_name += ".exe" - return package_dir / "bin" / binary_name - - @property - def url(self) -> str: - """AGFS service URL.""" - return f"http://localhost:{self.port}" - - def _check_port_available(self) -> None: - """Check if the port is available.""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - sock.bind(("127.0.0.1", self.port)) - except OSError as e: - raise RuntimeError( - f"AGFS port {self.port} is already in use, cannot start service. " - f"Please check if another AGFS process is running, or use a different port." - ) from e - finally: - sock.close() - - def _generate_config(self) -> Path: - """Dynamically generate AGFS configuration based on backend type.""" - config = { - "server": { - "address": f":{self.port}", - "log_level": self.log_level, - }, - "plugins": { - "serverinfofs": { - "enabled": True, - "path": "/serverinfo", - "config": { - "version": "1.0.0", - }, - }, - # TODO(multi-node): SQLite backend is single-node only. Each AGFS instance - # gets its own isolated queue.db under its own data_path, so messages - # enqueued on node A are invisible to node B. For multi-node deployments, - # switch backend to "tidb" or "mysql" so all nodes share the same queue. - # - # Additionally, the TiDB backend currently uses immediate soft-delete on - # Dequeue (no two-phase status='processing' transition), meaning there is - # no at-least-once guarantee: a worker crash loses the in-flight message. - # The TiDB backend's Ack() and RecoverStale() are both no-ops and must be - # implemented before it can be used safely in production. - "queuefs": { - "enabled": True, - "path": "/queue", - "config": { - "backend": "sqlite", - "db_path": str(self.data_path / "_system" / "queue" / "queue.db"), - }, - }, - }, - } - - if self.backend == "local": - config["plugins"]["localfs"] = { - "enabled": True, - "path": "/local", - "config": { - "local_dir": str(self.vikingfs_path), - }, - } - elif self.backend == "s3": - # AGFS S3 backend configuration (s3fs plugin) - # This enables AGFS to mount an S3 bucket as a local filesystem. - # Implementation details: third_party/agfs/agfs-server/pkg/plugins/s3fs/s3fs.go - s3_plugin_config = { - "bucket": self.s3_config.bucket, - "region": self.s3_config.region, - "access_key_id": self.s3_config.access_key, - "secret_access_key": self.s3_config.secret_key, - "endpoint": self.s3_config.endpoint, - "prefix": self.s3_config.prefix, - "disable_ssl": not self.s3_config.use_ssl, - "use_path_style": self.s3_config.use_path_style, - "directory_marker_mode": self.s3_config.directory_marker_mode.value, - "disable_batch_delete": self.s3_config.disable_batch_delete, - } - - config["plugins"]["s3fs"] = { - "enabled": True, - "path": "/local", - "config": s3_plugin_config, - } - elif self.backend == "memory": - config["plugins"]["memfs"] = { - "enabled": True, - "path": "/local", - } - return config - - def _generate_config_file(self) -> Path: - """Dynamically generate AGFS configuration file based on backend type.""" - config = self._generate_config() - config_dir = self.data_path / ".agfs" - config_dir.mkdir(parents=True, exist_ok=True) - config_file = config_dir / "config.yaml" - - with open(config_file, "w") as f: - yaml.dump(config, f, default_flow_style=False) - - self.config_file = config_file - return config_file - - def start(self) -> None: - """Start the AGFS server.""" - if self.process is not None and self.process.poll() is None: - logger.info("[AGFSManager] AGFS already running") - return - - # Check if port is available - self._check_port_available() - - self.vikingfs_path.mkdir(parents=True, exist_ok=True) - (self.data_path / "_system" / "queue").mkdir(parents=True, exist_ok=True) - # NOTICE: should use viking://temp/ instead of self.vikingfs_path / "temp" - # Create temp directory for Parser use - # (self.vikingfs_path / "temp").mkdir(exist_ok=True) - config_file = self._generate_config_file() - - if not self.binary_path.exists(): - raise FileNotFoundError( - f"AGFS binary not found at {self.binary_path}. " - "Please build AGFS first: cd third_party/agfs/agfs-server && make build && cp build/agfs-server ../bin/" - ) - - logger.info(f"[AGFSManager] Starting AGFS on port {self.port} with backend {self.backend}") - self.process = subprocess.Popen( - [str(self.binary_path), "-c", str(config_file)], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - - self._wait_for_ready() - logger.info(f"[AGFSManager] AGFS started at {self.url}") - - def _wait_for_ready(self, timeout: float = 5.0) -> None: - """Wait for AGFS service to be ready.""" - import requests - - logger.info(f"[AGFSManager] Waiting for AGFS to be ready at {self.url}/api/v1/health") - logger.info(f"[AGFSManager] Config file: {self.config_file}") - - start_time = time.time() - while time.time() - start_time < timeout: - try: - resp = requests.get(f"{self.url}/api/v1/health", timeout=0.5) - if resp.status_code == 200: - logger.info("[AGFSManager] AGFS is ready") - return - except requests.RequestException as e: - logger.debug(f"[AGFSManager] Health check failed: {e}") - - time.sleep(0.1) - - # Timeout, try reading output - logger.error( - f"[AGFSManager] Timeout after {timeout}s, process still running: {self.process.poll() is None}" - ) - raise TimeoutError(f"AGFS failed to start within {timeout}s") - - def stop(self) -> None: - """Stop the AGFS server.""" - if self.process is None: - return - - if self.process.poll() is None: - logger.info("[AGFSManager] Stopping AGFS") - self.process.terminate() - try: - self.process.wait(timeout=5.0) - except subprocess.TimeoutExpired: - logger.warning("[AGFSManager] AGFS not responding, killing") - self.process.kill() - self.process.wait() - - # Close pipes to prevent ResourceWarning - if self.process.stdout: - self.process.stdout.close() - if self.process.stderr: - self.process.stderr.close() - - self.process = None - - def is_running(self) -> bool: - """Check if AGFS is running.""" - return self.process is not None and self.process.poll() is None diff --git a/openviking/eval/ragas/playback.py b/openviking/eval/ragas/playback.py index 48baa1c83..b3be04c3b 100644 --- a/openviking/eval/ragas/playback.py +++ b/openviking/eval/ragas/playback.py @@ -212,7 +212,6 @@ def _init_backends(self) -> None: os.environ["OPENVIKING_CONFIG_FILE"] = self.config_file - from openviking.agfs_manager import AGFSManager from openviking.storage.viking_fs import init_viking_fs from openviking.storage.viking_vector_index_backend import VikingVectorIndexBackend from openviking.utils.agfs_utils import create_agfs_client @@ -221,19 +220,8 @@ def _init_backends(self) -> None: config = get_openviking_config() agfs_config = config.storage.agfs - agfs_manager = None - - # Determine if we need to start AGFSManager for HTTP mode - mode = getattr(agfs_config, "mode", "http-client") - if mode == "http-client": - agfs_manager = AGFSManager(config=agfs_config) - agfs_manager.start() - logger.info( - f"[IOPlayback] Started AGFS manager in HTTP mode at {agfs_manager.url} " - f"with {agfs_config.backend} backend" - ) - # Create AGFS client using utility + # Create RAGFS client using utility agfs_client = create_agfs_client(agfs_config) vector_store = None @@ -347,7 +335,7 @@ def _compare_agfs_calls( ) return False - for recorded_call, actual_call in zip(recorded_calls, actual_calls): + for recorded_call, actual_call in zip(recorded_calls, actual_calls, strict=True): if isinstance(recorded_call, dict): recorded_op = recorded_call.get("operation") recorded_req = recorded_call.get("request") diff --git a/openviking/pyagfs/__init__.py b/openviking/pyagfs/__init__.py index 75704f3d2..7b487a27e 100644 --- a/openviking/pyagfs/__init__.py +++ b/openviking/pyagfs/__init__.py @@ -21,19 +21,9 @@ _logger = logging.getLogger(__name__) -# Directory that ships pre-built native libraries (Go .so/.dylib and Rust .so/.dylib). +# Directory that ships pre-built native libraries (Rust .so/.dylib). _LIB_DIR = Path(__file__).resolve().parent.parent / "lib" -# --------------------------------------------------------------------------- -# Binding implementation selection via RAGFS_IMPL environment variable. -# -# RAGFS_IMPL=auto (default) — Rust first, Go fallback -# RAGFS_IMPL=rust — Rust only, error if unavailable -# RAGFS_IMPL=go — Go only, error if unavailable -# --------------------------------------------------------------------------- - -_RAGFS_IMPL_ENV = os.environ.get("RAGFS_IMPL", "").lower() or None - def _find_ragfs_so(): """Locate the ragfs_python native extension inside openviking/lib/. @@ -78,94 +68,39 @@ def _load_rust_binding(): raise ImportError("Rust binding not available") -def _load_go_binding(): - """Attempt to load the Go (ctypes) binding client.""" - try: - from .binding_client import AGFSBindingClient as _Go - from .binding_client import FileHandle as _GoFH - - return _Go, _GoFH - except Exception: - raise ImportError("Go binding not available") - - -def _resolve_binding(impl: str): - """Return (AGFSBindingClient, BindingFileHandle) based on *impl*. - - *impl* should be one of ``"auto"``, ``"rust"``, or ``"go"``. - """ - - if impl == "rust": - try: - client, fh = _load_rust_binding() - _logger.info("RAGFS_IMPL=rust: loaded Rust binding") - return client, fh - except ImportError as exc: - raise ImportError( - "RAGFS_IMPL=rust but ragfs_python native library is not available: " + str(exc) - ) from exc - - if impl == "go": - try: - client, fh = _load_go_binding() - _logger.info("RAGFS_IMPL=go: loaded Go binding") - return client, fh - except (ImportError, OSError) as exc: - raise ImportError( - "RAGFS_IMPL=go but Go binding (libagfsbinding) is not available: " + str(exc) - ) from exc - - if impl == "auto": - # Rust first, Go fallback, silent None if neither available - try: - client, fh = _load_rust_binding() - _logger.info("RAGFS_IMPL=auto: loaded Rust binding (ragfs-python)") - return client, fh - except Exception: - pass - - try: - client, fh = _load_go_binding() - _logger.info("RAGFS_IMPL=auto: Rust unavailable, loaded Go binding (libagfsbinding)") - return client, fh - except Exception: - pass - - _logger.warning( - "RAGFS_IMPL=auto: neither Rust nor Go binding available; AGFSBindingClient will be None" - ) - return None, None - - raise ValueError(f"Invalid RAGFS_IMPL value: '{impl}'. Must be one of: auto, rust, go") - - -def get_binding_client(config_impl: str = "auto"): - """Resolve binding classes with env-var override. - - Priority: ``RAGFS_IMPL`` env var > *config_impl* > ``"auto"`` +def get_binding_client(): + """Get the RAGFS binding client class. Returns: - ``(AGFSBindingClient_class, BindingFileHandle_class)`` + ``(RAGFSBindingClient_class, BindingFileHandle_class)`` """ - effective = _RAGFS_IMPL_ENV or config_impl or "auto" - return _resolve_binding(effective) + try: + client, fh = _load_rust_binding() + _logger.info("Loaded RAGFS Rust binding") + return client, fh + except ImportError as exc: + raise ImportError("ragfs_python native library is not available: " + str(exc)) from exc -# Module-level defaults (used when importing ``from openviking.pyagfs import AGFSBindingClient``) +# Module-level defaults # Ensure module import never fails, even if bindings are unavailable try: - AGFSBindingClient, BindingFileHandle = _resolve_binding(_RAGFS_IMPL_ENV or "auto") + RAGFSBindingClient, BindingFileHandle = get_binding_client() + # Backward compatibility alias + AGFSBindingClient = RAGFSBindingClient except Exception: _logger.warning( - "Failed to initialize AGFSBindingClient during module import; " - "AGFSBindingClient will be None. Use get_binding_client() for explicit handling." + "Failed to initialize RAGFSBindingClient during module import; " + "RAGFSBindingClient will be None. Use get_binding_client() for explicit handling." ) + RAGFSBindingClient = None AGFSBindingClient = None BindingFileHandle = None __all__ = [ "AGFSClient", "AGFSBindingClient", + "RAGFSBindingClient", "FileHandle", "BindingFileHandle", "get_binding_client", diff --git a/openviking/pyagfs/binding_client.py b/openviking/pyagfs/binding_client.py deleted file mode 100644 index 8a6b70cc9..000000000 --- a/openviking/pyagfs/binding_client.py +++ /dev/null @@ -1,638 +0,0 @@ -"""AGFS Python Binding Client - Direct binding to AGFS Server implementation""" - -import ctypes -import json -import os -import platform -from pathlib import Path -from typing import Any, BinaryIO, Dict, Iterator, List, Optional, Union - -from .exceptions import AGFSClientError, AGFSNotSupportedError - - -def _find_library() -> str: - """Find the AGFS binding shared library.""" - system = platform.system() - - if system == "Darwin": - lib_name = "libagfsbinding.dylib" - elif system == "Linux": - lib_name = "libagfsbinding.so" - elif system == "Windows": - lib_name = "libagfsbinding.dll" - else: - raise AGFSClientError(f"Unsupported platform: {system}") - - search_paths = [ - Path(__file__).parent / "lib" / lib_name, - Path(__file__).parent.parent / "lib" / lib_name, - Path(__file__).parent.parent.parent / "lib" / lib_name, - Path("/usr/local/lib") / lib_name, - Path("/usr/lib") / lib_name, - Path(os.environ.get("AGFS_LIB_PATH", "")) / lib_name - if os.environ.get("AGFS_LIB_PATH") - else None, - ] - - for path in search_paths: - if path and path.exists(): - return str(path) - - raise AGFSClientError( - f"Could not find {lib_name}. Please set AGFS_LIB_PATH environment variable " - f"or install the library to /usr/local/lib" - ) - - -class BindingLib: - """Wrapper for the AGFS binding shared library.""" - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._load_library() - return cls._instance - - def _load_library(self): - lib_path = _find_library() - self.lib = ctypes.CDLL(lib_path) - self._setup_functions() - - def _setup_functions(self): - self.lib.AGFS_NewClient.argtypes = [] - self.lib.AGFS_NewClient.restype = ctypes.c_int64 - - self.lib.AGFS_FreeClient.argtypes = [ctypes.c_int64] - self.lib.AGFS_FreeClient.restype = None - - self.lib.AGFS_GetLastError.argtypes = [ctypes.c_int64] - self.lib.AGFS_GetLastError.restype = ctypes.c_char_p - - self.lib.AGFS_FreeString.argtypes = [ctypes.c_char_p] - self.lib.AGFS_FreeString.restype = None - - self.lib.AGFS_Health.argtypes = [ctypes.c_int64] - self.lib.AGFS_Health.restype = ctypes.c_int - - self.lib.AGFS_GetCapabilities.argtypes = [ctypes.c_int64] - self.lib.AGFS_GetCapabilities.restype = ctypes.c_char_p - - self.lib.AGFS_Ls.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Ls.restype = ctypes.c_char_p - - self.lib.AGFS_Read.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_int64, - ctypes.c_int64, - ctypes.POINTER(ctypes.c_char_p), - ctypes.POINTER(ctypes.c_int64), - ] - self.lib.AGFS_Read.restype = ctypes.c_int64 - - self.lib.AGFS_Write.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_void_p, - ctypes.c_int64, - ] - self.lib.AGFS_Write.restype = ctypes.c_char_p - - self.lib.AGFS_Create.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Create.restype = ctypes.c_char_p - - self.lib.AGFS_Mkdir.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_uint] - self.lib.AGFS_Mkdir.restype = ctypes.c_char_p - - self.lib.AGFS_Rm.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_int] - self.lib.AGFS_Rm.restype = ctypes.c_char_p - - self.lib.AGFS_Stat.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Stat.restype = ctypes.c_char_p - - self.lib.AGFS_Mv.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_char_p] - self.lib.AGFS_Mv.restype = ctypes.c_char_p - - self.lib.AGFS_Chmod.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_uint] - self.lib.AGFS_Chmod.restype = ctypes.c_char_p - - self.lib.AGFS_Touch.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Touch.restype = ctypes.c_char_p - - self.lib.AGFS_Mounts.argtypes = [ctypes.c_int64] - self.lib.AGFS_Mounts.restype = ctypes.c_char_p - - self.lib.AGFS_Mount.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.c_char_p, - ] - self.lib.AGFS_Mount.restype = ctypes.c_char_p - - self.lib.AGFS_Unmount.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Unmount.restype = ctypes.c_char_p - - self.lib.AGFS_LoadPlugin.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_LoadPlugin.restype = ctypes.c_char_p - - self.lib.AGFS_UnloadPlugin.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_UnloadPlugin.restype = ctypes.c_char_p - - self.lib.AGFS_ListPlugins.argtypes = [ctypes.c_int64] - self.lib.AGFS_ListPlugins.restype = ctypes.c_char_p - - self.lib.AGFS_OpenHandle.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_int, - ctypes.c_uint, - ctypes.c_int, - ] - self.lib.AGFS_OpenHandle.restype = ctypes.c_int64 - - self.lib.AGFS_CloseHandle.argtypes = [ctypes.c_int64] - self.lib.AGFS_CloseHandle.restype = ctypes.c_char_p - - self.lib.AGFS_HandleRead.argtypes = [ - ctypes.c_int64, - ctypes.c_int64, - ctypes.c_int64, - ctypes.c_int, - ] - self.lib.AGFS_HandleRead.restype = ctypes.c_char_p - - self.lib.AGFS_HandleWrite.argtypes = [ - ctypes.c_int64, - ctypes.c_void_p, - ctypes.c_int64, - ctypes.c_int64, - ctypes.c_int, - ] - self.lib.AGFS_HandleWrite.restype = ctypes.c_char_p - - self.lib.AGFS_HandleSeek.argtypes = [ctypes.c_int64, ctypes.c_int64, ctypes.c_int] - self.lib.AGFS_HandleSeek.restype = ctypes.c_char_p - - self.lib.AGFS_HandleSync.argtypes = [ctypes.c_int64] - self.lib.AGFS_HandleSync.restype = ctypes.c_char_p - - self.lib.AGFS_HandleStat.argtypes = [ctypes.c_int64] - self.lib.AGFS_HandleStat.restype = ctypes.c_char_p - - self.lib.AGFS_ListHandles.argtypes = [ctypes.c_int64] - self.lib.AGFS_ListHandles.restype = ctypes.c_char_p - - self.lib.AGFS_GetHandleInfo.argtypes = [ctypes.c_int64] - self.lib.AGFS_GetHandleInfo.restype = ctypes.c_char_p - - self.lib.AGFS_Grep.argtypes = [ - ctypes.c_int64, # clientID - ctypes.c_char_p, # path - ctypes.c_char_p, # pattern - ctypes.c_int, # recursive - ctypes.c_int, # caseInsensitive - ctypes.c_int, # stream - ctypes.c_int, # nodeLimit - ] - self.lib.AGFS_Grep.restype = ctypes.c_char_p - -class AGFSBindingClient: - """Client for interacting with AGFS using Python binding (no HTTP server required). - - This client directly uses the AGFS server implementation through a shared library, - providing better performance than the HTTP client by avoiding network overhead. - - The interface is compatible with the HTTP client (AGFSClient), allowing easy - switching between implementations. - """ - - def __init__(self, config_path: Optional[str] = None): - """ - Initialize AGFS binding client. - - Args: - config_path: Optional path to configuration file (not used in binding mode). - """ - self._lib = BindingLib() - self._client_id = self._lib.lib.AGFS_NewClient() - if self._client_id <= 0: - raise AGFSClientError("Failed to create AGFS client") - - def __del__(self): - if hasattr(self, "_client_id") and self._client_id > 0: - try: - self._lib.lib.AGFS_FreeClient(self._client_id) - except Exception: - pass - - def _parse_response(self, result: bytes) -> Dict[str, Any]: - """Parse JSON response from the library.""" - if isinstance(result, bytes): - result = result.decode("utf-8") - data = json.loads(result) - - if "error_id" in data and data["error_id"] != 0: - error_msg = self._lib.lib.AGFS_GetLastError(data["error_id"]) - if isinstance(error_msg, bytes): - error_msg = error_msg.decode("utf-8") - raise AGFSClientError(error_msg if error_msg else "Unknown error") - - return data - - def health(self) -> Dict[str, Any]: - """Check client health.""" - result = self._lib.lib.AGFS_Health(self._client_id) - return {"status": "healthy" if result == 1 else "unhealthy"} - - def get_capabilities(self) -> Dict[str, Any]: - """Get client capabilities.""" - result = self._lib.lib.AGFS_GetCapabilities(self._client_id) - return self._parse_response(result) - - def ls(self, path: str = "/") -> List[Dict[str, Any]]: - """List directory contents.""" - result = self._lib.lib.AGFS_Ls(self._client_id, path.encode("utf-8")) - data = self._parse_response(result) - return data.get("files", []) - - def read(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): - return self.cat(path, offset, size, stream) - - def cat(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): - """Read file content with optional offset and size.""" - if stream: - raise AGFSNotSupportedError("Streaming not supported in binding mode") - - result_ptr = ctypes.c_char_p() - size_ptr = ctypes.c_int64() - - error_id = self._lib.lib.AGFS_Read( - self._client_id, - path.encode("utf-8"), - ctypes.c_int64(offset), - ctypes.c_int64(size), - ctypes.byref(result_ptr), - ctypes.byref(size_ptr), - ) - - if error_id < 0: - error_msg = self._lib.lib.AGFS_GetLastError(error_id) - if isinstance(error_msg, bytes): - error_msg = error_msg.decode("utf-8") - raise AGFSClientError(error_msg if error_msg else "Unknown error") - - if result_ptr: - data = ctypes.string_at(result_ptr, size_ptr.value) - return data - - return b"" - - def write( - self, path: str, data: Union[bytes, Iterator[bytes], BinaryIO], max_retries: int = 3 - ) -> str: - """Write data to file.""" - if not isinstance(data, bytes): - if hasattr(data, "read"): - data = data.read() - else: - data = b"".join(data) - - result = self._lib.lib.AGFS_Write( - self._client_id, path.encode("utf-8"), data, ctypes.c_int64(len(data)) - ) - resp = self._parse_response(result) - return resp.get("message", "OK") - - def create(self, path: str) -> Dict[str, Any]: - """Create a new file.""" - result = self._lib.lib.AGFS_Create(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def mkdir(self, path: str, mode: str = "755") -> Dict[str, Any]: - """Create a directory.""" - mode_int = int(mode, 8) - result = self._lib.lib.AGFS_Mkdir( - self._client_id, path.encode("utf-8"), ctypes.c_uint(mode_int) - ) - return self._parse_response(result) - - def rm(self, path: str, recursive: bool = False) -> Dict[str, Any]: - """Remove a file or directory.""" - result = self._lib.lib.AGFS_Rm(self._client_id, path.encode("utf-8"), 1 if recursive else 0) - return self._parse_response(result) - - def stat(self, path: str) -> Dict[str, Any]: - """Get file/directory information.""" - result = self._lib.lib.AGFS_Stat(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def mv(self, old_path: str, new_path: str) -> Dict[str, Any]: - """Rename/move a file or directory.""" - result = self._lib.lib.AGFS_Mv( - self._client_id, old_path.encode("utf-8"), new_path.encode("utf-8") - ) - return self._parse_response(result) - - def chmod(self, path: str, mode: int) -> Dict[str, Any]: - """Change file permissions.""" - result = self._lib.lib.AGFS_Chmod( - self._client_id, path.encode("utf-8"), ctypes.c_uint(mode) - ) - return self._parse_response(result) - - def touch(self, path: str) -> Dict[str, Any]: - """Touch a file.""" - result = self._lib.lib.AGFS_Touch(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def mounts(self) -> List[Dict[str, Any]]: - """List all mounted plugins.""" - result = self._lib.lib.AGFS_Mounts(self._client_id) - data = self._parse_response(result) - return data.get("mounts", []) - - def mount(self, fstype: str, path: str, config: Dict[str, Any]) -> Dict[str, Any]: - """Mount a plugin dynamically.""" - config_json = json.dumps(config) - result = self._lib.lib.AGFS_Mount( - self._client_id, - fstype.encode("utf-8"), - path.encode("utf-8"), - config_json.encode("utf-8"), - ) - return self._parse_response(result) - - def unmount(self, path: str) -> Dict[str, Any]: - """Unmount a plugin.""" - result = self._lib.lib.AGFS_Unmount(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def load_plugin(self, library_path: str) -> Dict[str, Any]: - """Load an external plugin.""" - result = self._lib.lib.AGFS_LoadPlugin(self._client_id, library_path.encode("utf-8")) - return self._parse_response(result) - - def unload_plugin(self, library_path: str) -> Dict[str, Any]: - """Unload an external plugin.""" - result = self._lib.lib.AGFS_UnloadPlugin(self._client_id, library_path.encode("utf-8")) - return self._parse_response(result) - - def list_plugins(self) -> List[str]: - """List all loaded external plugins.""" - result = self._lib.lib.AGFS_ListPlugins(self._client_id) - data = self._parse_response(result) - return data.get("loaded_plugins", []) - - def get_plugins_info(self) -> List[dict]: - """Get detailed information about all loaded plugins.""" - return self.list_plugins() - - def grep( - self, - path: str, - pattern: str, - recursive: bool = False, - case_insensitive: bool = False, - stream: bool = False, - node_limit: Optional[int] = None, - ): - """Search for a pattern in files. - - Args: - path: Path to file or directory to search - pattern: Regular expression pattern to search for - recursive: Whether to search recursively in directories (default: False) - case_insensitive: Whether to perform case-insensitive matching (default: False) - stream: Whether to stream results (not supported in binding mode, default: False) - node_limit: Maximum number of results to return (default: None) - - Returns: - Dict with 'matches' (list of match objects) and 'count' - """ - if stream: - raise AGFSNotSupportedError("Streaming not supported in binding mode") - - result = self._lib.lib.AGFS_Grep( - self._client_id, - path.encode("utf-8"), - pattern.encode("utf-8"), - 1 if recursive else 0, - 1 if case_insensitive else 0, - 0, # stream not supported - node_limit if node_limit is not None else 0, - ) - return self._parse_response(result) - - def digest(self, path: str, algorithm: str = "xxh3") -> Dict[str, Any]: - """Calculate the digest of a file.""" - raise AGFSNotSupportedError("Digest not supported in binding mode") - - def open_handle( - self, path: str, flags: int = 0, mode: int = 0o644, lease: int = 60 - ) -> "FileHandle": - """Open a file handle for stateful operations.""" - handle_id = self._lib.lib.AGFS_OpenHandle( - self._client_id, path.encode("utf-8"), flags, ctypes.c_uint(mode), lease - ) - - if handle_id < 0: - raise AGFSClientError("Failed to open handle") - - return FileHandle(self, handle_id, path, flags) - - def list_handles(self) -> List[Dict[str, Any]]: - """List all active file handles.""" - result = self._lib.lib.AGFS_ListHandles(self._client_id) - data = self._parse_response(result) - return data.get("handles", []) - - def get_handle_info(self, handle_id: int) -> Dict[str, Any]: - """Get information about a specific handle.""" - result = self._lib.lib.AGFS_GetHandleInfo(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def close_handle(self, handle_id: int) -> Dict[str, Any]: - """Close a file handle.""" - result = self._lib.lib.AGFS_CloseHandle(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def handle_read(self, handle_id: int, size: int = -1, offset: Optional[int] = None) -> bytes: - """Read from a file handle.""" - has_offset = 1 if offset is not None else 0 - offset_val = offset if offset is not None else 0 - - result = self._lib.lib.AGFS_HandleRead( - ctypes.c_int64(handle_id), ctypes.c_int64(size), ctypes.c_int64(offset_val), has_offset - ) - - if isinstance(result, bytes): - return result - - data = json.loads(result.decode("utf-8") if isinstance(result, bytes) else result) - if "error_id" in data and data["error_id"] != 0: - error_msg = self._lib.lib.AGFS_GetLastError(data["error_id"]) - if isinstance(error_msg, bytes): - error_msg = error_msg.decode("utf-8") - raise AGFSClientError(error_msg if error_msg else "Unknown error") - - return result if isinstance(result, bytes) else result.encode("utf-8") - - def handle_write(self, handle_id: int, data: bytes, offset: Optional[int] = None) -> int: - """Write to a file handle.""" - has_offset = 1 if offset is not None else 0 - offset_val = offset if offset is not None else 0 - - result = self._lib.lib.AGFS_HandleWrite( - ctypes.c_int64(handle_id), - data, - ctypes.c_int64(len(data)), - ctypes.c_int64(offset_val), - has_offset, - ) - resp = self._parse_response(result) - return resp.get("bytes_written", 0) - - def handle_seek(self, handle_id: int, offset: int, whence: int = 0) -> int: - """Seek within a file handle.""" - result = self._lib.lib.AGFS_HandleSeek( - ctypes.c_int64(handle_id), ctypes.c_int64(offset), whence - ) - data = self._parse_response(result) - return data.get("position", 0) - - def handle_sync(self, handle_id: int) -> Dict[str, Any]: - """Sync a file handle.""" - result = self._lib.lib.AGFS_HandleSync(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def handle_stat(self, handle_id: int) -> Dict[str, Any]: - """Get file info via handle.""" - result = self._lib.lib.AGFS_HandleStat(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def renew_handle(self, handle_id: int, lease: int = 60) -> Dict[str, Any]: - """Renew the lease on a file handle.""" - return {"message": "lease renewed", "lease": lease} - - -class FileHandle: - """A file handle for stateful file operations. - - Supports context manager protocol for automatic cleanup. - """ - - O_RDONLY = 0 - O_WRONLY = 1 - O_RDWR = 2 - O_APPEND = 8 - O_CREATE = 16 - O_EXCL = 32 - O_TRUNC = 64 - - SEEK_SET = 0 - SEEK_CUR = 1 - SEEK_END = 2 - - def __init__(self, client: AGFSBindingClient, handle_id: int, path: str, flags: int): - self._client = client - self._handle_id = handle_id - self._path = path - self._flags = flags - self._closed = False - - @property - def handle_id(self) -> int: - """The handle ID.""" - return self._handle_id - - @property - def path(self) -> str: - """The file path.""" - return self._path - - @property - def flags(self) -> int: - """The open flags (numeric).""" - return self._flags - - @property - def closed(self) -> bool: - """Whether the handle is closed.""" - return self._closed - - def read(self, size: int = -1) -> bytes: - """Read from current position.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_read(self._handle_id, size) - - def read_at(self, size: int, offset: int) -> bytes: - """Read at specific offset (pread).""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_read(self._handle_id, size, offset) - - def write(self, data: bytes) -> int: - """Write at current position.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_write(self._handle_id, data) - - def write_at(self, data: bytes, offset: int) -> int: - """Write at specific offset (pwrite).""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_write(self._handle_id, data, offset) - - def seek(self, offset: int, whence: int = 0) -> int: - """Seek to position.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_seek(self._handle_id, offset, whence) - - def tell(self) -> int: - """Get current position.""" - return self.seek(0, self.SEEK_CUR) - - def sync(self) -> None: - """Flush data to storage.""" - if self._closed: - raise AGFSClientError("Handle is closed") - self._client.handle_sync(self._handle_id) - - def stat(self) -> Dict[str, Any]: - """Get file info.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_stat(self._handle_id) - - def info(self) -> Dict[str, Any]: - """Get handle info.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.get_handle_info(self._handle_id) - - def renew(self, lease: int = 60) -> Dict[str, Any]: - """Renew the handle lease.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.renew_handle(self._handle_id, lease) - - def close(self) -> None: - """Close the handle.""" - if not self._closed: - self._client.close_handle(self._handle_id) - self._closed = True - - def __enter__(self) -> "FileHandle": - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self.close() - - def __repr__(self) -> str: - status = "closed" if self._closed else "open" - return f"FileHandle(id={self._handle_id}, path={self._path}, flags={self._flags}, {status})" diff --git a/openviking/service/core.py b/openviking/service/core.py index e61483fe5..778890ad7 100644 --- a/openviking/service/core.py +++ b/openviking/service/core.py @@ -9,7 +9,6 @@ import os from typing import Any, Optional -from openviking.agfs_manager import AGFSManager from openviking.core.directories import DirectoryInitializer from openviking.crypto.config import bootstrap_encryption from openviking.resource.watch_scheduler import WatchScheduler @@ -68,7 +67,6 @@ def __init__( ) # Infrastructure - self._agfs_manager: Optional[AGFSManager] = None self._agfs_client: Optional[Any] = None self._queue_manager: Optional[QueueManager] = None self._vikingdb_manager: Optional[VikingDBManager] = None @@ -114,14 +112,7 @@ def _init_storage( """Initialize storage resources.""" from openviking.utils.agfs_utils import create_agfs_client - mode = getattr(config.agfs, "mode", "http-client") - if mode == "http-client": - self._agfs_manager = AGFSManager(config=config.agfs) - self._agfs_manager.start() - agfs_url = self._agfs_manager.url - config.agfs.url = agfs_url - - # Create AGFS client using utility + # Create RAGFS client using utility self._agfs_client = create_agfs_client(config.agfs) # Initialize QueueManager with agfs_client @@ -133,7 +124,7 @@ def _init_storage( max_concurrent_semantic=max_concurrent_semantic, ) else: - logger.warning("AGFS client not initialized, skipping queue manager") + logger.warning("RAGFS client not initialized, skipping queue manager") # Initialize VikingDBManager with QueueManager self._vikingdb_manager = VikingDBManager( @@ -146,9 +137,9 @@ def _init_storage( if self._queue_manager: self._queue_manager.setup_standard_queues(self._vikingdb_manager, start=False) - # Initialize LockManager (fail-fast if AGFS missing) + # Initialize LockManager (fail-fast if RAGFS missing) if self._agfs_client is None: - raise RuntimeError("AGFS client not initialized for LockManager") + raise RuntimeError("RAGFS client not initialized for LockManager") tx_cfg = config.transaction self._lock_manager = init_lock_manager( agfs=self._agfs_client, @@ -369,10 +360,6 @@ async def close(self) -> None: await self._vikingdb_manager.close() self._vikingdb_manager = None - if self._agfs_manager: - self._agfs_manager.stop() - self._agfs_manager = None - self._viking_fs = None self._resource_processor = None self._skill_processor = None diff --git a/openviking/storage/queuefs/semantic_processor.py b/openviking/storage/queuefs/semantic_processor.py index a7b5f6d01..dee5f22a6 100644 --- a/openviking/storage/queuefs/semantic_processor.py +++ b/openviking/storage/queuefs/semantic_processor.py @@ -1048,7 +1048,7 @@ async def _generate_overview( if not vlm.is_available(): logger.warning("VLM not available, using default overview") - return f"# {dir_uri.split('/')[-1]}\n\nDirectory overview" + return f"# {dir_uri.split('/')[-1]}\n\n[Directory overview is not ready]" from openviking.session.memory.utils.language import _detect_language_from_text @@ -1164,7 +1164,7 @@ def replace_index(match): f"Failed to generate overview for {dir_uri}: {e}", exc_info=True, ) - return f"# {dir_uri.split('/')[-1]}\n\nDirectory overview" + return f"# {dir_uri.split('/')[-1]}\n\n[Directory overview is not generated]" async def _batched_generate_overview( self, @@ -1255,7 +1255,7 @@ async def _run_batch(batch_idx: int, prompt: str, batch_index_map: Dict[int, str partial_overviews = [p for p in partial_overviews if p is not None] if not partial_overviews: - return f"# {dir_name}\n\nDirectory overview" + return f"# {dir_name}\n\n[Directory overview is not generated]" # If only one batch succeeded, use it directly if len(partial_overviews) == 1: diff --git a/openviking/storage/viking_fs.py b/openviking/storage/viking_fs.py index 3cbdfa439..2d2360dcd 100644 --- a/openviking/storage/viking_fs.py +++ b/openviking/storage/viking_fs.py @@ -153,15 +153,13 @@ def get_viking_fs() -> "VikingFS": class VikingFS: - """AGFS-based OpenViking file system. + """RAGFS-based OpenViking file system. APIs are divided into two categories: - - AGFS basic commands (direct forwarding): read, ls, write, mkdir, rm, mv, grep, stat + - RAGFS basic commands (direct forwarding): read, ls, write, mkdir, rm, mv, grep, stat - VikingFS specific capabilities: abstract, overview, find, search, relations, link, unlink - Supports two modes: - - HTTP mode: Use AGFSClient to connect to AGFS server via HTTP - - Binding mode: Use AGFSBindingClient to directly use AGFS implementation + Uses Rust binding mode: Use RAGFSBindingClient to directly use RAGFS implementation """ def __init__( @@ -844,10 +842,14 @@ async def abstract( self._ensure_access(uri, ctx) path = self._uri_to_path(uri, ctx=ctx) info = self.agfs.stat(path) - if not info.get("isDir"): + if not info.get("isDir", info.get("is_dir")): raise ValueError(f"{uri} is not a directory") file_path = f"{path}/.abstract.md" - content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + try: + content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + except Exception: + # Fallback to default if .abstract.md doesn't exist + return f"# {uri}\n\n[Directory abstract is not ready]" if self._encryptor: real_ctx = self._ctx_or_default(ctx) @@ -864,10 +866,14 @@ async def overview( self._ensure_access(uri, ctx) path = self._uri_to_path(uri, ctx=ctx) info = self.agfs.stat(path) - if not info.get("isDir"): + if not info.get("isDir", info.get("is_dir")): raise ValueError(f"{uri} is not a directory") file_path = f"{path}/.overview.md" - content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + try: + content_bytes = self._handle_agfs_read(self.agfs.read(file_path)) + except Exception: + # Fallback to default if .overview.md doesn't exist + return f"# {uri}\n\n[Directory overview is not ready]" if self._encryptor: real_ctx = self._ctx_or_default(ctx) diff --git a/openviking/utils/agfs_utils.py b/openviking/utils/agfs_utils.py index deae50683..a8d7654a9 100644 --- a/openviking/utils/agfs_utils.py +++ b/openviking/utils/agfs_utils.py @@ -1,126 +1,146 @@ # Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. # SPDX-License-Identifier: AGPL-3.0 """ -AGFS Client utilities for creating and configuring AGFS clients. +RAGFS Client utilities for creating and configuring RAGFS clients. """ import os from pathlib import Path -from typing import Any +from typing import Any, Dict from openviking_cli.utils.logger import get_logger logger = get_logger(__name__) +def _generate_plugin_config(agfs_config: Any, data_path: Path) -> Dict[str, Any]: + """Dynamically generate RAGFS plugin configuration based on backend type.""" + config = { + "serverinfofs": { + "enabled": True, + "path": "/serverinfo", + "config": { + "version": "1.0.0", + }, + }, + "queuefs": { + "enabled": True, + "path": "/queue", + "config": { + "backend": "sqlite", + "db_path": str(data_path / "_system" / "queue" / "queue.db"), + }, + }, + } + + backend = getattr(agfs_config, "backend", "local") + s3_config = getattr(agfs_config, "s3", None) + vikingfs_path = data_path / "viking" + + if backend == "local": + config["localfs"] = { + "enabled": True, + "path": "/local", + "config": { + "local_dir": str(vikingfs_path), + }, + } + elif backend == "s3" and s3_config: + s3_plugin_config = { + "bucket": s3_config.bucket, + "region": s3_config.region, + "access_key_id": s3_config.access_key, + "secret_access_key": s3_config.secret_key, + "endpoint": s3_config.endpoint, + "prefix": s3_config.prefix, + "disable_ssl": not s3_config.use_ssl, + "use_path_style": s3_config.use_path_style, + "directory_marker_mode": s3_config.directory_marker_mode.value + if hasattr(s3_config.directory_marker_mode, "value") + else s3_config.directory_marker_mode, + "disable_batch_delete": s3_config.disable_batch_delete, + } + + config["s3fs"] = { + "enabled": True, + "path": "/local", + "config": s3_plugin_config, + } + elif backend == "memory": + config["memfs"] = { + "enabled": True, + "path": "/local", + } + return config + + def create_agfs_client(agfs_config: Any) -> Any: """ - Create an AGFS client based on the provided configuration. + Create a RAGFS client based on the provided configuration. Args: - agfs_config: AGFS configuration object containing mode and other settings. + agfs_config: RAGFS configuration object. Returns: - An AGFSClient or AGFSBindingClient instance. + A RAGFSBindingClient instance. """ # Ensure agfs_config is not None if agfs_config is None: raise ValueError("agfs_config cannot be None") - mode = getattr(agfs_config, "mode", "http-client") - - if mode == "binding-client": - # Import binding client if mode is binding-client - # Use get_binding_client() to respect RAGFS_IMPL env var > config.impl > "auto" - from openviking.pyagfs import get_binding_client - - config_impl = getattr(agfs_config, "impl", "auto") - env_impl = os.environ.get("RAGFS_IMPL", "").lower() or None - effective_impl = env_impl or config_impl or "auto" - AGFSBindingClient, _ = get_binding_client(config_impl) - - if AGFSBindingClient is None: - raise ImportError( - "AGFS binding client is not available. The native library (libagfsbinding) " - "could not be loaded. Please run 'pip install -e .' in the project root " - "to build and install the AGFS SDK with native bindings." - ) - - # Go ctypes binding needs AGFS_LIB_PATH and a shared library on disk. - # Rust PyO3 binding is compiled into ragfs_python — skip library checks. - try: - from openviking.pyagfs.binding_client import ( - AGFSBindingClient as _GoBindingClient, - ) - - is_go_binding = AGFSBindingClient is _GoBindingClient - except (ImportError, OSError): - is_go_binding = False - - if is_go_binding: - lib_path = getattr(agfs_config, "lib_path", None) - if lib_path and lib_path not in ["1", "default"]: - os.environ["AGFS_LIB_PATH"] = lib_path - else: - os.environ["AGFS_LIB_PATH"] = str(Path(__file__).parent.parent / "lib") - - try: - from openviking.pyagfs.binding_client import _find_library - - _find_library() - except Exception: - raise ImportError( - "AGFS binding library not found. Please run 'pip install -e .' in the project root to build and install the AGFS SDK." - ) - - client = AGFSBindingClient() - binding_type = "Rust (ragfs-python)" if not is_go_binding else "Go (libagfsbinding)" - logger.warning( - f"[AGFS] Binding impl selected: {binding_type} " - f"(RAGFS_IMPL={effective_impl}, env={env_impl}, config={config_impl})" + + # Import binding client + from openviking.pyagfs import get_binding_client + + RAGFSBindingClient, _ = get_binding_client() + + if RAGFSBindingClient is None: + raise ImportError( + "RAGFS binding client is not available. The native library (ragfs_python) " + "could not be loaded. Please run 'pip install -e .' in the project root " + "to build and install the RAGFS SDK with native bindings." ) - # Automatically mount backend for binding client - mount_agfs_backend(client, agfs_config) + client = RAGFSBindingClient() + logger.warning("[RAGFS] Using Rust binding (ragfs-python)") - return client - else: - # Default to http-client - from openviking.pyagfs import AGFSClient + # Automatically mount backend for binding client + mount_agfs_backend(client, agfs_config) - url = getattr(agfs_config, "url", "http://localhost:8080") - timeout = getattr(agfs_config, "timeout", 10) - client = AGFSClient(api_base_url=url, timeout=timeout) - logger.info(f"[AGFSUtils] Created AGFSClient at {url}") - return client + return client def mount_agfs_backend(agfs: Any, agfs_config: Any) -> None: """ - Mount backend filesystem for an AGFS client based on configuration. + Mount backend filesystem for a RAGFS client based on configuration. Args: - agfs: AGFS client instance (HTTP or Binding). - agfs_config: AGFS configuration object containing backend settings. + agfs: RAGFS client instance. + agfs_config: RAGFS configuration object containing backend settings. """ - from openviking.agfs_manager import AGFSManager - - # Only binding-client needs manual mounting. HTTP server handles its own mounting. - # Check for the presence of a `mount` method as the duck-type indicator for - # binding clients (works for both Rust and Go implementations). + # Check for the presence of a `mount` method if not callable(getattr(agfs, "mount", None)): return - # 1. Mount standard plugins to align with HTTP server behavior - agfs_manager = AGFSManager(agfs_config) - config = agfs_manager._generate_config() + path_str = getattr(agfs_config, "path", None) + if path_str is None: + raise ValueError("agfs_config.path is required for mounting backend") + + data_path = Path(path_str).resolve() + vikingfs_path = data_path / "viking" + + vikingfs_path.mkdir(parents=True, exist_ok=True) + (data_path / "_system" / "queue").mkdir(parents=True, exist_ok=True) + + # 1. Mount standard plugins + config = _generate_plugin_config(agfs_config, data_path) - for plugin_name, plugin_config in config["plugins"].items(): + for plugin_name, plugin_config in config.items(): mount_path = plugin_config["path"] # Ensure localfs directory exists before mounting if plugin_name == "localfs" and "local_dir" in plugin_config.get("config", {}): local_dir = plugin_config["config"]["local_dir"] os.makedirs(local_dir, exist_ok=True) - logger.debug(f"[AGFSUtils] Ensured local directory exists: {local_dir}") + logger.debug(f"[RAGFSUtils] Ensured local directory exists: {local_dir}") # Ensure queuefs db_path parent directory exists before mounting if plugin_name == "queuefs" and "db_path" in plugin_config.get("config", {}): db_path = plugin_config["config"]["db_path"] @@ -132,6 +152,6 @@ def mount_agfs_backend(agfs: Any, agfs_config: Any) -> None: pass try: agfs.mount(plugin_name, mount_path, plugin_config.get("config", {})) - logger.debug(f"[AGFSUtils] Successfully mounted {plugin_name} at {mount_path}") + logger.debug(f"[RAGFSUtils] Successfully mounted {plugin_name} at {mount_path}") except Exception as e: - logger.error(f"[AGFSUtils] Failed to mount {plugin_name} at {mount_path}: {e}") + logger.error(f"[RAGFSUtils] Failed to mount {plugin_name} at {mount_path}: {e}") diff --git a/openviking_cli/utils/config/agfs_config.py b/openviking_cli/utils/config/agfs_config.py index 1c694c590..0007a4da6 100644 --- a/openviking_cli/utils/config/agfs_config.py +++ b/openviking_cli/utils/config/agfs_config.py @@ -26,7 +26,7 @@ class S3Config(BaseModel): access_key: Optional[str] = Field( default=None, - description="S3 access key ID. If not provided, AGFS may attempt to use environment variables or IAM roles.", + description="S3 access key ID. If not provided, RAGFS may attempt to use environment variables or IAM roles.", ) secret_key: Optional[str] = Field( @@ -90,55 +90,22 @@ def validate_config(self): class AGFSConfig(BaseModel): - """Configuration for AGFS (Agent Global File System).""" + """Configuration for RAGFS (Rust-based AGFS).""" path: Optional[str] = Field( default=None, - description="[Deprecated in favor of `storage.workspace`] AGFS data storage path. This will be ignored if `storage.workspace` is set.", - ) - - port: int = Field(default=1833, description="AGFS service port") - - log_level: str = Field(default="warn", description="AGFS log level") - - url: Optional[str] = Field( - default="http://localhost:1833", description="AGFS service URL for service mode" - ) - - mode: str = Field( - default="binding-client", - description="AGFS client mode: 'http-client' | 'binding-client'", - ) - - impl: str = Field( - default="auto", - description="Binding implementation to use when mode is 'binding-client'. " - "'auto' = Rust first with Go fallback, 'rust' = Rust only, 'go' = Go only. " - "Can be overridden by the RAGFS_IMPL environment variable.", + description="[Deprecated in favor of `storage.workspace`] RAGFS data storage path. This will be ignored if `storage.workspace` is set.", ) backend: str = Field( - default="local", description="AGFS storage backend: 'local' | 's3' | 'memory'" - ) - - timeout: int = Field(default=10, description="AGFS request timeout (seconds)") - - retry_times: int = Field(default=3, description="AGFS retry times on failure") - - use_ssl: bool = Field( - default=True, - description="Enable/Disable SSL (HTTPS) for AGFS service. Set to False for local testing without HTTPS.", + default="local", description="RAGFS storage backend: 'local' | 's3' | 'memory'" ) - lib_path: Optional[str] = Field( - default=None, - description="Path to AGFS binding shared library. If set, use python binding instead of HTTP client. " - "Default: third_party/agfs/bin/libagfsbinding.{so,dylib}", - ) + timeout: int = Field(default=10, description="RAGFS request timeout (seconds)") # S3 backend configuration # These settings are used when backend is set to 's3'. - # AGFS will act as a gateway to the specified S3 bucket. + # RAGFS will act as a gateway to the specified S3 bucket. s3: S3Config = Field(default_factory=lambda: S3Config(), description="S3 backend configuration") model_config = {"extra": "forbid"} @@ -146,19 +113,9 @@ class AGFSConfig(BaseModel): @model_validator(mode="after") def validate_config(self): """Validate configuration completeness and consistency""" - if self.mode not in ["http-client", "binding-client"]: - raise ValueError( - f"Invalid AGFS mode: '{self.mode}'. Must be one of: 'http-client', 'binding-client'" - ) - - if self.impl not in ["auto", "rust", "go"]: - raise ValueError( - f"Invalid AGFS impl: '{self.impl}'. Must be one of: 'auto', 'rust', 'go'" - ) - if self.backend not in ["local", "s3", "memory"]: raise ValueError( - f"Invalid AGFS backend: '{self.backend}'. Must be one of: 'local', 's3', 'memory'" + f"Invalid RAGFS backend: '{self.backend}'. Must be one of: 'local', 's3', 'memory'" ) if self.backend == "local": diff --git a/openviking_cli/utils/config/open_viking_config.py b/openviking_cli/utils/config/open_viking_config.py index 9273a1c72..ef4b1ae1a 100644 --- a/openviking_cli/utils/config/open_viking_config.py +++ b/openviking_cli/utils/config/open_viking_config.py @@ -20,7 +20,6 @@ ) from .embedding_config import EmbeddingConfig from .encryption_config import EncryptionConfig -from .telemetry_config import TelemetryConfig from .log_config import LogConfig from .memory_config import MemoryConfig from .parser_config import ( @@ -39,6 +38,7 @@ from .prompts_config import PromptsConfig from .rerank_config import RerankConfig from .storage_config import StorageConfig +from .telemetry_config import TelemetryConfig from .vlm_config import VLMConfig @@ -385,16 +385,6 @@ def is_valid_openviking_config(config: OpenVikingConfig) -> bool: if not config.default_account or not config.default_account.strip(): errors.append("Default account identifier cannot be empty") - # Validate service mode vs embedded mode consistency - is_service_mode = config.storage.vectordb.backend == "http" - is_agfs_local = config.storage.agfs.backend == "local" - - if is_service_mode and is_agfs_local and not config.storage.agfs.url: - errors.append( - "Service mode (VectorDB backend='http') with local AGFS backend requires 'agfs.url' to be set. " - "Consider using AGFS backend='s3' or provide remote AGFS URL." - ) - if errors: error_message = "Invalid OpenViking configuration:\n" + "\n".join( f" - {e}" for e in errors diff --git a/pyproject.toml b/pyproject.toml index 07093717f..6451144c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,8 +82,6 @@ dependencies = [ "lark-oapi>=1.5.3", ] -[tool.uv.sources] -pyagfs = { path = "third_party/agfs/agfs-sdk/python" } [project.optional-dependencies] test = [ @@ -203,11 +201,6 @@ exclude = ["tests*", "docs*", "examples*"] openviking = [ "prompts/templates/**/*.yaml", "console/static/**/*", - "bin/agfs-server", - "bin/agfs-server.exe", - "lib/libagfsbinding.so", - "lib/libagfsbinding.dylib", - "lib/libagfsbinding.dll", "lib/ragfs_python*.so", "lib/ragfs_python*.pyd", "bin/ov", diff --git a/setup.py b/setup.py index 3b1275f8f..b3aab6960 100644 --- a/setup.py +++ b/setup.py @@ -73,7 +73,6 @@ class OpenVikingBuildExt(build_ext): """Build OpenViking runtime artifacts and Python native extensions.""" def run(self): - self.build_agfs_artifacts() self.build_ov_cli_artifact() self.build_ragfs_python_artifact() self.cmake_executable = CMAKE_PATH @@ -140,156 +139,6 @@ def _resolve_cargo_target_dir(self, cargo_project_dir, env): return cargo_project_dir.parents[1] / "target" - def build_agfs_artifacts(self): - """Build or reuse the AGFS server binary and binding library.""" - binary_name = "agfs-server.exe" if sys.platform == "win32" else "agfs-server" - if sys.platform == "win32": - lib_name = "libagfsbinding.dll" - elif sys.platform == "darwin": - lib_name = "libagfsbinding.dylib" - else: - lib_name = "libagfsbinding.so" - - agfs_server_dir = Path("third_party/agfs/agfs-server").resolve() - agfs_bin_dir = Path("openviking/bin").resolve() - agfs_lib_dir = Path("openviking/lib").resolve() - agfs_target_binary = agfs_bin_dir / binary_name - agfs_target_lib = agfs_lib_dir / lib_name - - self._run_stage_with_artifact_checks( - "AGFS build", - lambda: self._build_agfs_artifacts_impl( - agfs_server_dir, - binary_name, - lib_name, - agfs_target_binary, - agfs_target_lib, - ), - [ - (agfs_target_binary, binary_name), - (agfs_target_lib, lib_name), - ], - on_success=lambda: self._copy_artifacts_to_build_lib( - agfs_target_binary, agfs_target_lib - ), - ) - - def _build_agfs_artifacts_impl( - self, agfs_server_dir, binary_name, lib_name, agfs_target_binary, agfs_target_lib - ): - """Implement AGFS artifact building without final artifact checks.""" - - prebuilt_dir = os.environ.get("OV_PREBUILT_BIN_DIR") - if prebuilt_dir: - prebuilt_path = Path(prebuilt_dir).resolve() - print(f"Checking for pre-built AGFS artifacts in {prebuilt_path}...") - src_bin = prebuilt_path / binary_name - src_lib = prebuilt_path / lib_name - - if src_bin.exists(): - self._copy_artifact(src_bin, agfs_target_binary) - if src_lib.exists(): - self._copy_artifact(src_lib, agfs_target_lib) - - if agfs_target_binary.exists() and agfs_target_lib.exists(): - print(f"[OK] Used pre-built AGFS artifacts from {prebuilt_dir}") - return - - if os.environ.get("OV_SKIP_AGFS_BUILD") == "1": - if agfs_target_binary.exists() and agfs_target_lib.exists(): - print("[OK] Skipping AGFS build, using existing artifacts") - return - print("[Warning] OV_SKIP_AGFS_BUILD=1 but artifacts are missing. Will try to build.") - - if agfs_server_dir.exists() and shutil.which("go"): - print("Building AGFS artifacts from source...") - - try: - print(f"Building AGFS server: {binary_name}") - env = os.environ.copy() - if "GOOS" in env or "GOARCH" in env: - print(f"Cross-compiling with GOOS={env.get('GOOS')} GOARCH={env.get('GOARCH')}") - - build_args = ( - ["go", "build", "-o", f"build/{binary_name}", "cmd/server/main.go"] - if sys.platform == "win32" - else ["make", "build"] - ) - - result = subprocess.run( - build_args, - cwd=str(agfs_server_dir), - env=env, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if result.stdout: - print(f"Build stdout: {result.stdout.decode('utf-8', errors='replace')}") - if result.stderr: - print(f"Build stderr: {result.stderr.decode('utf-8', errors='replace')}") - - agfs_built_binary = agfs_server_dir / "build" / binary_name - self._require_artifact(agfs_built_binary, binary_name, "AGFS server build") - self._copy_artifact(agfs_built_binary, agfs_target_binary) - print("[OK] AGFS server built successfully from source") - except Exception as exc: - error_msg = f"Failed to build AGFS server from source: {exc}" - if isinstance(exc, subprocess.CalledProcessError): - if exc.stdout: - error_msg += ( - f"\nBuild stdout:\n{exc.stdout.decode('utf-8', errors='replace')}" - ) - if exc.stderr: - error_msg += ( - f"\nBuild stderr:\n{exc.stderr.decode('utf-8', errors='replace')}" - ) - print(f"[Error] {error_msg}") - raise RuntimeError(error_msg) - - try: - print(f"Building AGFS binding library: {lib_name}") - env = os.environ.copy() - env["CGO_ENABLED"] = "1" - - result = subprocess.run( - ["make", "build-lib"], - cwd=str(agfs_server_dir), - env=env, - check=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - if result.stdout: - print(f"Build stdout: {result.stdout.decode('utf-8', errors='replace')}") - if result.stderr: - print(f"Build stderr: {result.stderr.decode('utf-8', errors='replace')}") - - agfs_built_lib = agfs_server_dir / "build" / lib_name - self._require_artifact(agfs_built_lib, lib_name, "AGFS binding build") - self._copy_artifact(agfs_built_lib, agfs_target_lib) - print("[OK] AGFS binding library built successfully") - except Exception as exc: - error_msg = f"Failed to build AGFS binding library: {exc}" - if isinstance(exc, subprocess.CalledProcessError): - if exc.stdout: - error_msg += ( - f"\nBuild stdout: {exc.stdout.decode('utf-8', errors='replace')}" - ) - if exc.stderr: - error_msg += ( - f"\nBuild stderr: {exc.stderr.decode('utf-8', errors='replace')}" - ) - print(f"[Error] {error_msg}") - raise RuntimeError(error_msg) - else: - if agfs_target_binary.exists() and agfs_target_lib.exists(): - print("[Info] AGFS artifacts already exist locally. Skipping source build.") - elif not agfs_server_dir.exists(): - print(f"[Warning] AGFS source directory not found at {agfs_server_dir}") - else: - print("[Warning] Go compiler not found. Cannot build AGFS from source.") - def build_ov_cli_artifact(self): """Build or reuse the ov Rust CLI binary.""" binary_name = "ov.exe" if sys.platform == "win32" else "ov" @@ -359,11 +208,11 @@ def _build_ov_cli_artifact_impl(self, ov_cli_dir, binary_name, ov_target_binary) if isinstance(exc, subprocess.CalledProcessError): if exc.stdout: error_msg += ( - f"\nBuild stdout: {exc.stdout.decode('utf-8', errors='replace')}" + f"\nBuild stdout:\n{exc.stdout.decode('utf-8', errors='replace')}" ) if exc.stderr: error_msg += ( - f"\nBuild stderr: {exc.stderr.decode('utf-8', errors='replace')}" + f"\nBuild stderr:\n{exc.stderr.decode('utf-8', errors='replace')}" ) print(f"[Error] {error_msg}") raise RuntimeError(error_msg) @@ -376,11 +225,8 @@ def _build_ov_cli_artifact_impl(self, ov_cli_dir, binary_name, ov_target_binary) print("[Warning] Cargo not found. Cannot build ov CLI from source.") def build_ragfs_python_artifact(self): - """Build ragfs-python (Rust AGFS binding) via maturin and copy the native + """Build ragfs-python (Rust RAGFS binding) via maturin and copy the native extension into ``openviking/lib/`` so it ships inside the openviking wheel. - - This is a best-effort build — the Go binding serves as fallback, - so failure here is non-fatal. """ ragfs_python_dir = Path("crates/ragfs-python").resolve() ragfs_lib_dir = Path("openviking/lib").resolve() @@ -396,8 +242,7 @@ def build_ragfs_python_artifact(self): if importlib.util.find_spec("maturin") is None: print( "[SKIP] maturin not found. ragfs-python (Rust binding) will not be built.\n" - " Install maturin to enable: pip install maturin\n" - " The Go binding will be used as fallback." + " Install maturin to enable: pip install maturin" ) return @@ -406,7 +251,7 @@ def build_ragfs_python_artifact(self): with tempfile.TemporaryDirectory() as tmpdir: try: - print("Building ragfs-python (Rust AGFS binding) via maturin...") + print("Building ragfs-python (Rust RAGFS binding) via maturin...") env = os.environ.copy() build_args = [ sys.executable, @@ -474,7 +319,6 @@ def build_ragfs_python_artifact(self): print(f"[Warning] Failed to build ragfs-python: {exc}") if error_detail: print(error_detail) - print(" The Go binding will be used as fallback.") def build_extension(self, ext): """Build a single Python native extension artifact using CMake.""" @@ -562,9 +406,6 @@ def finalize_options(self): setup( - # install_requires=[ - # f"pyagfs @ file://localhost/{os.path.abspath('third_party/agfs/agfs-sdk/python')}" - # ], ext_modules=[ Extension( name=ENGINE_BUILD_CONFIG.primary_extension, @@ -575,11 +416,6 @@ def finalize_options(self): cmdclass=cmdclass, package_data={ "openviking": [ - "bin/agfs-server", - "bin/agfs-server.exe", - "lib/libagfsbinding.so", - "lib/libagfsbinding.dylib", - "lib/libagfsbinding.dll", "lib/ragfs_python*.so", "lib/ragfs_python*.pyd", "bin/ov", diff --git a/tests/agfs/test_fs_local.py b/tests/agfs/test_fs_local.py deleted file mode 100644 index a92a1b551..000000000 --- a/tests/agfs/test_fs_local.py +++ /dev/null @@ -1,135 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 - -"""AGFS Local Backend Tests for VikingFS interface""" - -import os -import shutil -import uuid - -import pytest - -from openviking.agfs_manager import AGFSManager -from openviking.storage.transaction import init_lock_manager, reset_lock_manager -from openviking.storage.viking_fs import init_viking_fs -from openviking_cli.utils.config.agfs_config import AGFSConfig - -# 1. Direct configuration for testing -AGFS_CONF = AGFSConfig( - path="/tmp/ov-test", - backend="local", - port=1833, - mode="http-client", - url="http://localhost:1833", - timeout=10, -) - -# clean up test directory if it exists -if os.path.exists(AGFS_CONF.path): - shutil.rmtree(AGFS_CONF.path) - - -@pytest.fixture(scope="module") -async def viking_fs_instance(): - """Initialize AGFS Manager and VikingFS singleton.""" - from openviking.utils.agfs_utils import create_agfs_client - - manager = AGFSManager(config=AGFS_CONF) - manager.start() - - # Create AGFS client - agfs_client = create_agfs_client(AGFS_CONF) - - # Initialize LockManager and VikingFS with client - init_lock_manager(agfs=agfs_client) - vfs = init_viking_fs(agfs=agfs_client) - # make sure default/temp directory exists - await vfs.mkdir("viking://temp/", exist_ok=True) - - yield vfs - - reset_lock_manager() - # AGFSManager.stop is synchronous - manager.stop() - - -@pytest.mark.asyncio -class TestVikingFSLocal: - """Test VikingFS operations with local backend.""" - - async def test_file_operations(self, viking_fs_instance): - """Test VikingFS file operations: read, write, ls, stat.""" - vfs = viking_fs_instance - - test_filename = f"local_file_{uuid.uuid4().hex}.txt" - test_content = "Hello VikingFS Local! " + uuid.uuid4().hex - test_uri = f"viking://temp/{test_filename}" - - # 1. Write file - await vfs.write(test_uri, test_content) - - # 2. Stat file - stat_info = await vfs.stat(test_uri) - assert stat_info["name"] == test_filename - assert not stat_info["isDir"] - - # 3. List directory - entries = await vfs.ls("viking://temp/") - assert any(e["name"] == test_filename for e in entries) - - # 4. Read file - read_data = await vfs.read(test_uri) - assert read_data.decode("utf-8") == test_content - - # Cleanup - await vfs.rm(test_uri) - - async def test_directory_operations(self, viking_fs_instance): - """Test VikingFS directory operations: mkdir, rm, ls, stat.""" - vfs = viking_fs_instance - test_dir = f"local_dir_{uuid.uuid4().hex}" - test_dir_uri = f"viking://temp/{test_dir}/" - - # 1. Create directory - await vfs.mkdir(test_dir_uri) - - # 2. Stat directory - stat_info = await vfs.stat(test_dir_uri) - assert stat_info["name"] == test_dir - assert stat_info["isDir"] - - # 3. List root to see directory - root_entries = await vfs.ls("viking://temp/") - assert any(e["name"] == test_dir and e["isDir"] for e in root_entries) - - # 4. Write a file inside - file_uri = f"{test_dir_uri}inner.txt" - await vfs.write(file_uri, "inner content") - - # 5. List subdirectory - sub_entries = await vfs.ls(test_dir_uri) - assert any(e["name"] == "inner.txt" for e in sub_entries) - - # 6. Delete directory (recursive) - await vfs.rm(test_dir_uri, recursive=True) - - # 7. Verify deletion - root_entries = await vfs.ls("viking://temp/") - assert not any(e["name"] == test_dir for e in root_entries) - - async def test_ensure_dirs(self, viking_fs_instance): - """Test VikingFS ensure_dirs.""" - vfs = viking_fs_instance - base_dir = f"local_tree_test_{uuid.uuid4().hex}" - sub_dir = f"viking://temp/{base_dir}/a/b/" - file_uri = f"{sub_dir}leaf.txt" - - await vfs.mkdir(sub_dir) - await vfs.write(file_uri, "leaf content") - - # VikingFS.tree provides recursive listing - entries = await vfs.tree(f"viking://temp/{base_dir}/") - assert any("leaf.txt" in e["uri"] for e in entries) - - # Cleanup - await vfs.rm(f"viking://temp/{base_dir}/", recursive=True) diff --git a/tests/agfs/test_fs_s3.py b/tests/agfs/test_fs_s3.py deleted file mode 100644 index eb75825e2..000000000 --- a/tests/agfs/test_fs_s3.py +++ /dev/null @@ -1,192 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 - -"""AGFS S3 Backend Tests for VikingFS interface with S3 client verification""" - -import json -import os -import uuid -from pathlib import Path - -import boto3 -import botocore -import pytest - -from openviking.agfs_manager import AGFSManager -from openviking.storage.transaction import init_lock_manager, reset_lock_manager -from openviking.storage.viking_fs import VikingFS, init_viking_fs -from openviking_cli.utils.config.agfs_config import AGFSConfig - -# 1. Simplified Config loading logic -# Only extract the AGFS part for focused testing -CONFIG_FILE = os.getenv("OPENVIKING_CONFIG_FILE") -if not CONFIG_FILE: - # Try default ov.conf in tests/agfs - default_conf = Path(__file__).parent / "ov.conf" - if default_conf.exists(): - CONFIG_FILE = str(default_conf) - - -def load_agfs_config() -> AGFSConfig: - """Load only AGFS configuration from the config file.""" - if not CONFIG_FILE or not Path(CONFIG_FILE).exists(): - return None - - try: - with open(CONFIG_FILE, "r") as f: - full_config = json.load(f) - - # Support both 'storage.agfs' and top-level 'agfs' structures - agfs_data = full_config.get("storage", {}).get("agfs") or full_config.get("agfs") - if not agfs_data: - return None - - return AGFSConfig(**agfs_data) - except Exception: - return None - - -AGFS_CONF = load_agfs_config() -if AGFS_CONF is not None: - AGFS_CONF.mode = "http-client" - -# 2. Skip tests if no S3 config found or backend is not S3 -pytestmark = pytest.mark.skipif( - AGFS_CONF is None or AGFS_CONF.backend != "s3", - reason="AGFS S3 configuration not found in ov.conf", -) - - -@pytest.fixture(scope="module") -def s3_client(): - """Boto3 client for S3 verification.""" - - s3_conf = AGFS_CONF.s3 - return boto3.client( - "s3", - aws_access_key_id=s3_conf.access_key, - aws_secret_access_key=s3_conf.secret_key, - region_name=s3_conf.region, - endpoint_url=s3_conf.endpoint, - use_ssl=s3_conf.use_ssl, - ) - - -@pytest.fixture(scope="module") -async def viking_fs_instance(): - """Initialize AGFS Manager and VikingFS singleton.""" - from openviking.utils.agfs_utils import create_agfs_client - - manager = AGFSManager(config=AGFS_CONF) - manager.start() - - # Create AGFS client - agfs_client = create_agfs_client(AGFS_CONF) - - # Initialize LockManager and VikingFS with client - init_lock_manager(agfs=agfs_client) - vfs = init_viking_fs(agfs=agfs_client) - - yield vfs - - reset_lock_manager() - # AGFSManager.stop is synchronous - manager.stop() - - -@pytest.mark.asyncio -class TestVikingFSS3: - """Test VikingFS operations with S3 backend and verify via S3 client.""" - - async def test_file_operations(self, viking_fs_instance: "VikingFS", s3_client): - """Test VikingFS file operations and verify with S3 client.""" - vfs = viking_fs_instance - s3_conf = AGFS_CONF.s3 - bucket = s3_conf.bucket - prefix = s3_conf.prefix or "" - - test_filename = f"verify_{uuid.uuid4().hex}.txt" - test_content = "Hello VikingFS S3! " + uuid.uuid4().hex - test_uri = f"viking://temp/{test_filename}" - - # 1. Write via VikingFS - await vfs.write(test_uri, test_content) - - # 2. Verify existence and content via S3 client - # VikingFS maps viking://temp/{test_filename} to /local/default/temp/{test_filename} - s3_key = f"{prefix}default/temp/{test_filename}" - response = s3_client.get_object(Bucket=bucket, Key=s3_key) - s3_content = response["Body"].read().decode("utf-8") - assert s3_content == test_content - - # 3. Stat via VikingFS - stat_info = await vfs.stat(test_uri) - assert stat_info["name"] == test_filename - assert not stat_info["isDir"] - - # 4. List via VikingFS - entries = await vfs.ls("viking://temp/") - assert any(e["name"] == test_filename for e in entries) - - # 5. Read back via VikingFS - read_data = await vfs.read(test_uri) - assert read_data.decode("utf-8") == test_content - - # 6. Cleanup via VikingFS - await vfs.rm(test_uri) - - # 7. Verify deletion via S3 client - with pytest.raises(botocore.exceptions.ClientError) as excinfo: - s3_client.get_object(Bucket=bucket, Key=s3_key) - assert excinfo.value.response["Error"]["Code"] in ["NoSuchKey", "404"] - - async def test_directory_operations(self, viking_fs_instance, s3_client): - """Test VikingFS directory operations and verify with S3 client.""" - vfs = viking_fs_instance - s3_conf = AGFS_CONF.s3 - bucket = s3_conf.bucket - prefix = s3_conf.prefix or "" - - test_dir = f"test_dir_{uuid.uuid4().hex}" - test_dir_uri = f"viking://temp/{test_dir}/" - - # 1. Create directory via VikingFS - await vfs.mkdir(test_dir_uri) - - # 2. Verify via S3 client by writing a file inside - file_uri = f"{test_dir_uri}inner.txt" - file_content = "inner content" - await vfs.write(file_uri, file_content) - - # VikingFS maps viking://temp/{test_dir}/inner.txt to /local/default/temp/{test_dir}/inner.txt - s3_key = f"{prefix}default/temp/{test_dir}/inner.txt" - response = s3_client.get_object(Bucket=bucket, Key=s3_key) - assert response["Body"].read().decode("utf-8") == file_content - - # 3. List via VikingFS - root_entries = await vfs.ls("viking://temp/") - assert any(e["name"] == test_dir and e["isDir"] for e in root_entries) - - # 4. Delete directory recursively via VikingFS - await vfs.rm(test_dir_uri, recursive=True) - - # 5. Verify deletion via S3 client - with pytest.raises(botocore.exceptions.ClientError): - s3_client.get_object(Bucket=bucket, Key=s3_key) - - async def test_ensure_dirs(self, viking_fs_instance: "VikingFS"): - """Test VikingFS ensure_dirs.""" - vfs = viking_fs_instance - base_dir = f"tree_test_{uuid.uuid4().hex}" - sub_dir = f"viking://temp/{base_dir}/a/b/" - file_uri = f"{sub_dir}leaf.txt" - - await vfs.mkdir(sub_dir) - await vfs.write(file_uri, "leaf content") - - # VikingFS.tree provides recursive listing - entries = await vfs.tree(f"viking://temp/{base_dir}/") - assert any("leaf.txt" in e["uri"] for e in entries) - - # Cleanup - await vfs.rm(f"viking://temp/{base_dir}/", recursive=True) diff --git a/tests/integration/test_add_resource_index.py b/tests/integration/test_add_resource_index.py index 3da7cc256..eb911123a 100644 --- a/tests/integration/test_add_resource_index.py +++ b/tests/integration/test_add_resource_index.py @@ -49,8 +49,6 @@ async def client(test_config, tmp_path): patch("openviking.utils.summarizer.Summarizer.summarize") as mock_summarize, patch("openviking.utils.index_builder.IndexBuilder.build_index") as mock_build_index, patch("openviking.utils.agfs_utils.create_agfs_client", return_value=mock_agfs), - patch("openviking.agfs_manager.AGFSManager.start"), - patch("openviking.agfs_manager.AGFSManager.stop"), ): # Make mocks return success mock_summarize.return_value = {"status": "success"} @@ -106,8 +104,6 @@ async def test_add_resource_indexing_logic(test_config, tmp_path): "openviking.utils.summarizer.Summarizer.summarize", new_callable=AsyncMock ) as mock_summarize, patch("openviking.utils.agfs_utils.create_agfs_client", return_value=mock_agfs), - patch("openviking.agfs_manager.AGFSManager.start"), - patch("openviking.agfs_manager.AGFSManager.stop"), patch( "openviking.utils.media_processor.UnifiedResourceProcessor.process", new_callable=AsyncMock, diff --git a/tests/integration/test_encryption_integration.py b/tests/integration/test_encryption_integration.py index 411c5679f..ac099cbd0 100644 --- a/tests/integration/test_encryption_integration.py +++ b/tests/integration/test_encryption_integration.py @@ -374,9 +374,9 @@ async def test_account_creation_and_encryption(self, openviking_service_with_enc assert user_key is not None assert len(user_key) == 64 - # AGFS /local/... paths map to test_data_dir/viking/viking/... + # RAGFS /local/... paths map to test_data_dir/viking/viking/... # because OpenVikingService path is test_data_dir/viking, - # and AGFSManager vikingfs_path is data_path/viking + # and RAGFS vikingfs_path is data_path/viking agfs_data_root = test_data_dir / "viking" / "viking" # Verify global accounts.json file created and encrypted diff --git a/tests/misc/test_agfs_s3_config.py b/tests/misc/test_agfs_s3_config.py deleted file mode 100644 index 2a17e9227..000000000 --- a/tests/misc/test_agfs_s3_config.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 - -import pytest - -from openviking.agfs_manager import AGFSManager -from openviking_cli.utils.config.agfs_config import AGFSConfig, DirectoryMarkerMode, S3Config - - -def _build_s3_config(**overrides) -> S3Config: - return S3Config( - bucket="my-bucket", - region="us-west-1", - access_key="fake-access-key-for-testing", - secret_key="fake-secret-key-for-testing-12345", - endpoint="https://tos-cn-beijing.volces.com", - **overrides, - ) - - -def test_s3_directory_marker_mode_defaults_to_empty(): - default_s3 = S3Config() - - assert default_s3.directory_marker_mode is DirectoryMarkerMode.EMPTY - - -def test_s3_rejects_removed_legacy_nonempty_directory_marker_alias(): - with pytest.raises(ValueError, match="Extra inputs are not permitted"): - _build_s3_config(nonempty_directory_marker=True) - - -@pytest.mark.parametrize( - ("mode", "expected"), - [ - (DirectoryMarkerMode.EMPTY, "empty"), - (DirectoryMarkerMode.NONEMPTY, "nonempty"), - (DirectoryMarkerMode.NONE, "none"), - ], -) -def test_agfs_manager_emits_directory_marker_mode_only(tmp_path, mode, expected): - config = AGFSConfig( - path=str(tmp_path), - backend="s3", - s3=_build_s3_config(directory_marker_mode=mode), - ) - - manager = AGFSManager(config=config) - agfs_config = manager._generate_config() - s3_plugin_config = agfs_config["plugins"]["s3fs"]["config"] - - assert s3_plugin_config["directory_marker_mode"] == expected diff --git a/tests/misc/test_port_check.py b/tests/misc/test_port_check.py deleted file mode 100644 index 87e6edab7..000000000 --- a/tests/misc/test_port_check.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 -"""Tests for AGFSManager._check_port_available() socket leak fix.""" - -import gc -import os -import socket -import sys -import warnings - -import pytest - -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) - -from openviking.agfs_manager import AGFSManager - - -def _make_manager(port: int) -> AGFSManager: - """Create a minimal AGFSManager with only the port attribute set.""" - mgr = AGFSManager.__new__(AGFSManager) - mgr.port = port - return mgr - - -class TestCheckPortAvailable: - """Test _check_port_available() properly closes sockets.""" - - def test_available_port_no_leak(self): - """Socket should be closed after successful port check.""" - mgr = _make_manager(0) # port 0 = OS picks a free port - # Should not raise and should not leak - mgr._check_port_available() - - def test_occupied_port_raises_runtime_error(self): - """Should raise RuntimeError when port is in use.""" - blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - blocker.bind(("localhost", 0)) - port = blocker.getsockname()[1] - blocker.listen(1) - - mgr = _make_manager(port) - try: - with pytest.raises(RuntimeError, match="already in use"): - mgr._check_port_available() - finally: - blocker.close() - - def test_occupied_port_no_resource_warning(self): - """Socket must be closed even when port is occupied (no ResourceWarning).""" - blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - blocker.bind(("localhost", 0)) - port = blocker.getsockname()[1] - blocker.listen(1) - - mgr = _make_manager(port) - try: - # Flush any ResourceWarnings accumulated from previous tests - with warnings.catch_warnings(record=True): - warnings.simplefilter("always", ResourceWarning) - gc.collect() - - with pytest.raises(RuntimeError): - mgr._check_port_available() - - # Now check only for new ResourceWarnings from _check_port_available - with warnings.catch_warnings(record=True) as w: - warnings.simplefilter("always", ResourceWarning) - gc.collect() - resource_warnings = [x for x in w if issubclass(x.category, ResourceWarning)] - assert len(resource_warnings) == 0, f"Socket leaked: {resource_warnings}" - finally: - blocker.close() diff --git a/tests/transaction/conftest.py b/tests/transaction/conftest.py deleted file mode 100644 index 20183dbad..000000000 --- a/tests/transaction/conftest.py +++ /dev/null @@ -1,139 +0,0 @@ -# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. -# SPDX-License-Identifier: AGPL-3.0 -"""Shared fixtures for transaction tests using real AGFS and VectorDB backends.""" - -import os -import shutil -import uuid - -import pytest - -from openviking.agfs_manager import AGFSManager -from openviking.server.identity import RequestContext, Role -from openviking.storage.collection_schemas import CollectionSchemas -from openviking.storage.transaction.lock_manager import LockManager -from openviking.storage.transaction.path_lock import LOCK_FILE_NAME, _make_fencing_token -from openviking.storage.transaction.redo_log import RedoLog -from openviking.storage.viking_vector_index_backend import VikingVectorIndexBackend -from openviking.utils.agfs_utils import create_agfs_client -from openviking_cli.session.user_id import UserIdentifier -from openviking_cli.utils.config.agfs_config import AGFSConfig -from openviking_cli.utils.config.vectordb_config import VectorDBBackendConfig - -AGFS_CONF = AGFSConfig( - path="/tmp/ov-tx-test", backend="local", port=1834, url="http://localhost:1834", timeout=10 -) - -VECTOR_DIM = 4 -COLLECTION_NAME = "tx_test_ctx" - -# Clean slate before session starts -if os.path.exists(AGFS_CONF.path): - shutil.rmtree(AGFS_CONF.path) - - -@pytest.fixture(scope="session") -def agfs_manager(): - manager = AGFSManager(config=AGFS_CONF) - manager.start() - yield manager - manager.stop() - - -@pytest.fixture(scope="session") -def agfs_client(agfs_manager): - return create_agfs_client(AGFS_CONF) - - -def _mkdir_ok(agfs_client, path): - """Create directory, ignoring already-exists errors.""" - try: - agfs_client.mkdir(path) - except Exception: - pass # already exists - - -@pytest.fixture -def test_dir(agfs_client): - path = f"/local/tx-tests/{uuid.uuid4().hex}" - _mkdir_ok(agfs_client, "/local") - _mkdir_ok(agfs_client, "/local/tx-tests") - _mkdir_ok(agfs_client, path) - yield path - try: - agfs_client.rm(path, recursive=True) - except Exception: - pass - - -# --------------------------------------------------------------------------- -# VectorDB fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture(scope="session") -def vector_store(tmp_path_factory): - """Session-scoped real local VectorDB backend.""" - db_path = str(tmp_path_factory.mktemp("vectordb")) - config = VectorDBBackendConfig( - backend="local", - name=COLLECTION_NAME, - path=db_path, - dimension=VECTOR_DIM, - ) - store = VikingVectorIndexBackend(config=config) - - import asyncio - - schema = CollectionSchemas.context_collection(COLLECTION_NAME, VECTOR_DIM) - asyncio.get_event_loop().run_until_complete(store.create_collection(COLLECTION_NAME, schema)) - - yield store - - asyncio.get_event_loop().run_until_complete(store.close()) - - -@pytest.fixture(scope="session") -def request_ctx(): - """Session-scoped RequestContext for VectorDB operations.""" - user = UserIdentifier("default", "test_user", "default") - return RequestContext(user=user, role=Role.ROOT) - - -# --------------------------------------------------------------------------- -# Lock fixtures -# --------------------------------------------------------------------------- - - -@pytest.fixture -def lock_manager(agfs_client): - """Function-scoped LockManager with real AGFS backend.""" - return LockManager(agfs=agfs_client, lock_timeout=1.0, lock_expire=1.0) - - -@pytest.fixture -def redo_log(agfs_client): - """Function-scoped RedoLog with real AGFS backend.""" - return RedoLog(agfs_client) - - -# --------------------------------------------------------------------------- -# Utility helpers -# --------------------------------------------------------------------------- - - -def file_exists(agfs_client, path) -> bool: - """Check if a file/dir exists in AGFS.""" - try: - agfs_client.stat(path) - return True - except Exception: - return False - - -def make_lock_file(agfs_client, dir_path, tx_id, lock_type="P") -> str: - """Create a real lock file in AGFS and return its path.""" - lock_path = f"{dir_path.rstrip('/')}/{LOCK_FILE_NAME}" - token = _make_fencing_token(tx_id, lock_type) - agfs_client.write(lock_path, token.encode("utf-8")) - return lock_path diff --git a/third_party/agfs/.github/workflows/daily-build.yml b/third_party/agfs/.github/workflows/daily-build.yml deleted file mode 100644 index a134216b0..000000000 --- a/third_party/agfs/.github/workflows/daily-build.yml +++ /dev/null @@ -1,265 +0,0 @@ -name: Daily Build - -on: - schedule: - # Run at 00:00 UTC every day - - cron: '0 0 * * *' - workflow_dispatch: # Allow manual trigger - -permissions: - contents: write - -jobs: - build: - name: Build for ${{ matrix.os }}-${{ matrix.arch }} - runs-on: ${{ matrix.runner }} - strategy: - matrix: - include: - # Linux builds - - os: linux - arch: amd64 - runner: ubuntu-latest - - os: linux - arch: arm64 - runner: ubuntu-24.04-arm - - # macOS builds - - os: darwin - arch: amd64 - runner: macos-latest - - os: darwin - arch: arm64 - runner: macos-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: '1.25.1' - cache-dependency-path: agfs-server/go.sum - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install uv - shell: bash - run: | - if [ "${{ matrix.os }}" = "windows" ]; then - # For Windows, use PowerShell installer - powershell -c "irm https://astral.sh/uv/install.ps1 | iex" - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - echo "$HOME/AppData/Roaming/Python/Scripts" >> $GITHUB_PATH - else - # For Unix - curl -LsSf https://astral.sh/uv/install.sh | sh - echo "$HOME/.cargo/bin" >> $GITHUB_PATH - fi - - - name: Get version info - id: version - shell: bash - run: | - echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT - echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT - - - name: Build agfs-server - working-directory: agfs-server - env: - GOOS: ${{ matrix.os }} - GOARCH: ${{ matrix.arch }} - CGO_ENABLED: 0 - run: | - go build -ldflags="-s -w -X main.version=${{ steps.version.outputs.date }}-${{ steps.version.outputs.short_sha }}" -o ../build/agfs-server-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.os == 'windows' && '.exe' || '' }} ./cmd/server - - - name: Build agfs-shell (portable) - if: matrix.arch == 'amd64' || matrix.arch == 'arm64' - shell: bash - run: | - cd agfs-shell - - # Find uv command - if command -v uv &> /dev/null; then - UV_CMD="uv" - elif [ -f "$HOME/.cargo/bin/uv" ]; then - UV_CMD="$HOME/.cargo/bin/uv" - else - echo "Error: uv not found" - exit 1 - fi - - echo "Using uv: $UV_CMD" - $UV_CMD --version - - # Sync dependencies - $UV_CMD sync - - # Build portable distribution - python3 build.py - - # Create archive name - ARCHIVE_NAME="agfs-shell-${{ matrix.os }}-${{ matrix.arch }}" - - # Package the portable distribution - if [ "${{ matrix.os }}" = "windows" ]; then - # For Windows, create zip - cd dist - powershell Compress-Archive -Path agfs-shell-portable -DestinationPath "../../build/${ARCHIVE_NAME}.zip" - else - # For Unix, create tar.gz - cd dist - tar -czf "../../build/${ARCHIVE_NAME}.tar.gz" agfs-shell-portable/ - fi - - - name: Create archive (Unix) - if: matrix.os != 'windows' - working-directory: build - run: | - tar -czf agfs-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.version.outputs.date }}.tar.gz agfs-server-${{ matrix.os }}-${{ matrix.arch }}* - - - name: Create archive (Windows) - if: matrix.os == 'windows' - working-directory: build - shell: pwsh - run: | - $files = Get-ChildItem -Filter "agfs-*-${{ matrix.os }}-${{ matrix.arch }}*" - Compress-Archive -Path $files -DestinationPath "agfs-${{ matrix.os }}-${{ matrix.arch }}-${{ steps.version.outputs.date }}.zip" - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: agfs-${{ matrix.os }}-${{ matrix.arch }} - path: | - build/agfs-${{ matrix.os }}-${{ matrix.arch }}-*.tar.gz - build/agfs-${{ matrix.os }}-${{ matrix.arch }}-*.zip - build/agfs-shell-${{ matrix.os }}-${{ matrix.arch }}.tar.gz - build/agfs-shell-${{ matrix.os }}-${{ matrix.arch }}.zip - retention-days: 90 - - create-release: - name: Create Daily Release - needs: build - runs-on: ubuntu-latest - if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Get version info - id: version - run: | - echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT - echo "tag=nightly" >> $GITHUB_OUTPUT - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: release-artifacts - - - name: Display structure of downloaded files - run: ls -R release-artifacts - - - name: Prepare release assets - run: | - mkdir -p release - find release-artifacts -type f \( -name "*.tar.gz" -o -name "*.zip" \) -exec cp {} release/ \; - - - name: Delete existing nightly release - continue-on-error: true - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release delete nightly --yes --cleanup-tag - - - name: Create Release - uses: softprops/action-gh-release@v2 - with: - tag_name: nightly - name: Nightly Build (${{ steps.version.outputs.date }}) - body: | - ## Daily Build - ${{ steps.version.outputs.date }} - - Automated daily build from commit ${{ github.sha }} - - ### 📦 What's Included - - This release contains: - - **agfs-server**: Go binary (server) - - **agfs-shell**: Python portable CLI with Unix-style pipeline support (requires Python 3.8+, includes all dependencies) - - ### Downloads - - #### Server Binaries - - - **Linux AMD64**: `agfs-linux-amd64-${{ steps.version.outputs.date }}.tar.gz` - - **Linux ARM64**: `agfs-linux-arm64-${{ steps.version.outputs.date }}.tar.gz` - - **macOS AMD64**: `agfs-darwin-amd64-${{ steps.version.outputs.date }}.tar.gz` - - **macOS ARM64 (Apple Silicon)**: `agfs-darwin-arm64-${{ steps.version.outputs.date }}.tar.gz` - - #### CLI Client (Portable, Python 3.8+ required) - - - **Linux AMD64**: `agfs-shell-linux-amd64.tar.gz` - - **Linux ARM64**: `agfs-shell-linux-arm64.tar.gz` - - **macOS AMD64**: `agfs-shell-darwin-amd64.tar.gz` - - **macOS ARM64**: `agfs-shell-darwin-arm64.tar.gz` - - ### Installation - - #### Quick Install (All-in-One) - - ```bash - curl -fsSL https://raw.githubusercontent.com/c4pt0r/agfs/master/install.sh | sh - ``` - - This will install both server and client to `~/.local/bin/`. - - #### Manual Installation - - **Server (Linux/macOS):** - ```bash - # Extract - tar -xzf agfs---${{ steps.version.outputs.date }}.tar.gz - - # Make executable - chmod +x agfs-server-- - - # Move to bin directory - mv agfs-server-- ~/.local/bin/agfs-server - - # Run server - agfs-server - ``` - - **Client (Linux/macOS):** - ```bash - # Extract - tar -xzf agfs-shell--.tar.gz - - # Run directly - ./agfs-shell-portable/agfs-shell - - # Or add to PATH - export PATH=$PATH:$(pwd)/agfs-shell-portable - ``` - - ### Quick Start - - ```bash - # Start the server - agfs-server - - # In another terminal, use CLI with Unix-style pipelines - agfs-shell - # Then run commands like: - # cat /etc/hosts | grep localhost - # ls / | grep etc - ``` - files: release/* - draft: false - prerelease: true diff --git a/third_party/agfs/.gitignore b/third_party/agfs/.gitignore deleted file mode 100644 index 380cffc27..000000000 --- a/third_party/agfs/.gitignore +++ /dev/null @@ -1,42 +0,0 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Code coverage profiles and other test artifacts -*.out -coverage.* -*.coverprofile -profile.cov - -# Dependency directories (remove the comment below to include it) -# vendor/ - -# Go workspace file -go.work -go.work.sum - -# env file -.env - -# Editor/IDE -# .idea/ -# .vscode/ - - -# config files - -build/ - -# python staging files -*.pyc -__pycache__/ -.idea diff --git a/third_party/agfs/LICENSE b/third_party/agfs/LICENSE deleted file mode 100644 index 261eeb9e9..000000000 --- a/third_party/agfs/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/third_party/agfs/README.md b/third_party/agfs/README.md deleted file mode 100644 index 4511c176c..000000000 --- a/third_party/agfs/README.md +++ /dev/null @@ -1,214 +0,0 @@ -# AGFS Logo - -[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Daily Build](https://github.com/c4pt0r/agfs/actions/workflows/daily-build.yml/badge.svg)](https://github.com/c4pt0r/agfs/actions/workflows/daily-build.yml) - -**Aggregated File System (Agent FS)** - Everything is a file, in RESTful APIs. A tribute to Plan9. - -## Why AGFS? - -When coordinating multiple AI Agents in a distributed environment, agents need access to various backend services: message queues, databases, object storage, KV stores, and more. The traditional approach requires writing specialized API calls for each service, meaning agents must understand many different interfaces. - -The core idea of AGFS is simple: **unify all services as file system operations**. - -``` -Traditional approach AGFS approach ------------------------------------------------------------------- -redis.set("key", "value") -> echo "value" > /kvfs/keys/mykey -sqs.send_message(queue, msg) -> echo "msg" > /queuefs/q/enqueue -s3.put_object(bucket, key, data) -> cp file /s3fs/bucket/key -mysql.execute("SELECT ...") -> echo "SELECT ..." > /sqlfs2/.../query -``` - -The benefits: - -1. **AI understands file operations natively** - Any LLM knows how to use cat, echo, and ls. No API documentation needed. -2. **Unified interface** - Operate all backends the same way, reducing cognitive overhead. -3. **Composability** - Combine services using pipes, redirections, and other shell features. -4. **Easy debugging** - Use ls and cat to inspect system state. - -## Quick Start - -Install: - -```bash -curl -fsSL https://raw.githubusercontent.com/c4pt0r/agfs/master/install.sh | sh -``` - -Or via Docker: - -```bash -docker pull c4pt0r/agfs-server:latest -``` - -Connect using agfs-shell: - -```bash -$ agfs -agfs:/> ls -queuefs/ kvfs/ s3fs/ sqlfs/ heartbeatfs/ memfs/ ... -``` - -## FUSE Support - -AGFS can be mounted as a native filesystem on Linux using FUSE. This allows any program to interact with AGFS services using standard file operations, not just the agfs-shell. - -```bash -# Mount AGFS to /mnt/agfs -agfs-fuse --agfs-server-url http://localhost:8080 --mount /mnt/agfs - -# Now use standard tools -ls /mnt/agfs/kvfs/keys/ -echo "hello" > /mnt/agfs/kvfs/keys/mykey -cat /mnt/agfs/queuefs/tasks/dequeue -``` - -This makes AGFS accessible to any application, script, or programming language that can read and write files. - -See [agfs-fuse/README.md](./agfs-fuse/README.md) for installation and usage. - -## Examples - -### Key-Value Store - -The simplest key-value storage. Filename is the key, content is the value: - -```bash -agfs:/> echo "world" > /kvfs/keys/hello # write -agfs:/> cat /kvfs/keys/hello # read -> "world" -agfs:/> ls /kvfs/keys/ # list all keys -hello -agfs:/> rm /kvfs/keys/hello # delete -``` - -### Message Queue - -A message queue is abstracted as a directory containing control files: - -```bash -agfs:/> mkdir /queuefs/tasks # create queue -agfs:/> ls /queuefs/tasks -enqueue dequeue peek size clear - -agfs:/> echo "job1" > /queuefs/tasks/enqueue # enqueue -019aa869-1a20-7ca6-a77a-b081e24c0593 - -agfs:/> cat /queuefs/tasks/size # check queue length -1 - -agfs:/> cat /queuefs/tasks/dequeue # dequeue -{"id":"019aa869-...","data":"job1","timestamp":"2025-11-21T13:54:11Z"} -``` - -This pattern is ideal for AI Agent task distribution: one agent writes tasks to the queue, another agent reads and executes them. - -### SQL Database - -Query databases through a Plan 9 style session interface: - -```bash -agfs:/> cat /sqlfs2/mydb/users/schema # view table structure -agfs:/> cat /sqlfs2/mydb/users/count # get row count - -# Create session, execute query, read result -agfs:/> sid=$(cat /sqlfs2/mydb/users/ctl) -agfs:/> echo "SELECT * FROM users LIMIT 2" > /sqlfs2/mydb/users/$sid/query -agfs:/> cat /sqlfs2/mydb/users/$sid/result -[{"id": 1, "name": "alice"}, {"id": 2, "name": "bob"}] -``` - -### Agent Heartbeat - -Manage the liveness state of distributed agents: - -```bash -agfs:/> mkdir /heartbeatfs/agent-1 # register agent -agfs:/> touch /heartbeatfs/agent-1/keepalive # send heartbeat - -agfs:/> cat /heartbeatfs/agent-1/ctl # check status -last_heartbeat_ts: 2025-11-21T13:55:45-08:00 -timeout: 30 -status: alive - -# After 30 seconds without a new heartbeat, the agent directory is automatically removed -``` - -### Cross-FS Operations - -Different filesystems can operate with each other: - -```bash -agfs:/> cp local:/tmp/data.txt /s3fs/mybucket/ # upload local file to S3 -agfs:/> cp /s3fs/mybucket/config.json /memfs/ # copy S3 file to memory -``` - -## AGFS Scripts - -AGFS shell supports scripting with `.as` files. Scripts use familiar shell syntax and can be executed directly. - -**task_worker.as** - A simple task queue worker: - -```bash -#!/usr/bin/env agfs - -QUEUE_PATH=/queuefs/tasks -POLL_INTERVAL=2 - -# Initialize queue -mkdir $QUEUE_PATH - -while true; do - size=$(cat $QUEUE_PATH/size) - - if [ "$size" = "0" ]; then - echo "Queue empty, waiting..." - sleep $POLL_INTERVAL - continue - fi - - # Dequeue and process task - task=$(cat $QUEUE_PATH/dequeue) - echo "Processing: $task" - - # Your task logic here -done -``` - -**enqueue_task.as** - Enqueue a task: - -```bash -#!/usr/bin/env agfs - -mkdir /queuefs/tasks -echo "$1" > /queuefs/tasks/enqueue -echo "Task enqueued. Queue size: $(cat /queuefs/tasks/size)" -``` - -Run scripts directly: - -```bash -./task_worker.as & -./enqueue_task.as "process report.pdf" -``` - -See more examples in [agfs-shell/examples](./agfs-shell/examples/). - -## Use Case: AI Agent Task Loop - -A typical agent coordination pattern: multiple agents fetch tasks from the same queue and execute them. - -```python -while True: - task = agfs.cat("/queuefs/tasks/dequeue") - if task: - result = execute_task(task) - agfs.write(f"/kvfs/keys/result_{task.id}", result) -``` - -See [task_loop.py](./agfs-mcp/demos/task_loop.py) for a complete example. - -## Documentation - -- [agfs-server](./agfs-server/README.md) - Server configuration and plugin development -- [agfs-shell](./agfs-shell/README.md) - Interactive shell client -- [agfs-fuse](./agfs-fuse/README.md) - FUSE filesystem mount (Linux) diff --git a/third_party/agfs/agfs-fuse/.gitignore b/third_party/agfs/agfs-fuse/.gitignore deleted file mode 100644 index 2462da5b1..000000000 --- a/third_party/agfs/agfs-fuse/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -# Binaries -bin/ -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool -*.out - -# Go workspace file -go.work - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Temporary files -tmp/ -temp/ diff --git a/third_party/agfs/agfs-fuse/Makefile b/third_party/agfs/agfs-fuse/Makefile deleted file mode 100644 index 1ed366032..000000000 --- a/third_party/agfs/agfs-fuse/Makefile +++ /dev/null @@ -1,29 +0,0 @@ -.PHONY: build install clean test - -# Binary name -BINARY=agfs-fuse - -# Build directory -BUILD_DIR=build - -# Installation directory -INSTALL_DIR=/usr/local/bin - -build: - @echo "Building $(BINARY)..." - @mkdir -p $(BUILD_DIR) - go build -o $(BUILD_DIR)/$(BINARY) ./cmd/agfs-fuse - -install: build - @echo "Installing $(BINARY) to $(INSTALL_DIR)..." - @sudo cp $(BUILD_DIR)/$(BINARY) $(INSTALL_DIR)/ - @echo "Installation complete" - -clean: - @echo "Cleaning build artifacts..." - @rm -rf $(BUILD_DIR) - @echo "Clean complete" - -test: - @echo "Running tests..." - go test -v ./... diff --git a/third_party/agfs/agfs-fuse/README.md b/third_party/agfs/agfs-fuse/README.md deleted file mode 100644 index ac5920375..000000000 --- a/third_party/agfs/agfs-fuse/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# AGFS FUSE [WIP] - -A FUSE filesystem implementation for mounting AGFS servers on Linux. - -## Platform Support - -Currently supports **Linux only**. - -## Prerequisites - -- Go 1.21.1 or higher -- FUSE development libraries -- Linux kernel with FUSE support - -Install FUSE on your system: -```bash -# Debian/Ubuntu -sudo apt-get install fuse3 libfuse3-dev - -# RHEL/Fedora/CentOS -sudo dnf install fuse3 fuse3-devel - -# Arch Linux -sudo pacman -S fuse3 -``` - -## Quick Start - -### Build - -```bash -# Using Makefile (recommended) -make build - -# Or build directly with Go -go build -o build/agfs-fuse ./cmd/agfs-fuse -``` - -### Install (Optional) - -```bash -# Install to /usr/local/bin -make install -``` - -### Mount - -```bash -# Basic usage -./build/agfs-fuse --agfs-server-url http://localhost:8080 --mount /mnt/agfs - -# With custom cache TTL -./build/agfs-fuse --agfs-server-url http://localhost:8080 --mount /mnt/agfs --cache-ttl=10s - -# Enable debug output -./build/agfs-fuse --agfs-server-url http://localhost:8080 --mount /mnt/agfs --debug - -# Allow other users to access the mount -./build/agfs-fuse --agfs-server-url http://localhost:8080 --mount /mnt/agfs --allow-other -``` - -### Unmount - -Press `Ctrl+C` in the terminal where agfs-fuse is running, or use: -```bash -fusermount -u /mnt/agfs -``` - -## Usage - -``` -agfs-fuse [options] - -Options: - -agfs-server-url string - AGFS server URL (required) - -mount string - Mount point directory (required) - -cache-ttl duration - Cache TTL duration (default 5s) - -debug - Enable debug output - -allow-other - Allow other users to access the mount - -version - Show version information -``` - -## License - -See LICENSE file for details. diff --git a/third_party/agfs/agfs-fuse/cmd/agfs-fuse/main.go b/third_party/agfs/agfs-fuse/cmd/agfs-fuse/main.go deleted file mode 100644 index 51dc5851d..000000000 --- a/third_party/agfs/agfs-fuse/cmd/agfs-fuse/main.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "os" - "os/signal" - "path/filepath" - "runtime" - "syscall" - "time" - - "github.com/dongxuny/agfs-fuse/pkg/fusefs" - "github.com/dongxuny/agfs-fuse/pkg/version" - "github.com/hanwen/go-fuse/v2/fs" - "github.com/hanwen/go-fuse/v2/fuse" - log "github.com/sirupsen/logrus" -) - -func main() { - var ( - serverURL = flag.String("agfs-server-url", "http://localhost:8080", "AGFS server URL") - mountpoint = flag.String("mount", "", "Mount point directory") - cacheTTL = flag.Duration("cache-ttl", 5*time.Second, "Cache TTL duration") - debug = flag.Bool("debug", false, "Enable debug output") - logLevel = flag.String("log-level", "info", "Log level (debug, info, warn, error)") - allowOther = flag.Bool("allow-other", false, "Allow other users to access the mount") - showVersion = flag.Bool("version", false, "Show version information") - ) - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0]) - fmt.Fprintf(os.Stderr, "Mount AGFS server as a FUSE filesystem.\n\n") - fmt.Fprintf(os.Stderr, "Options:\n") - flag.PrintDefaults() - fmt.Fprintf(os.Stderr, "\nExamples:\n") - fmt.Fprintf(os.Stderr, " %s --agfs-server-url http://localhost:8080 --mount /mnt/agfs\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s --agfs-server-url http://localhost:8080 --mount /mnt/agfs --cache-ttl=10s\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " %s --agfs-server-url http://localhost:8080 --mount /mnt/agfs --debug\n", os.Args[0]) - } - - flag.Parse() - - // Show version - if *showVersion { - fmt.Printf("agfs-fuse %s\n", version.GetFullVersion()) - os.Exit(0) - } - - // Initialize logrus - level := log.InfoLevel - if *debug { - level = log.DebugLevel - } else if *logLevel != "" { - if parsedLevel, err := log.ParseLevel(*logLevel); err == nil { - level = parsedLevel - } - } - log.SetFormatter(&log.TextFormatter{ - FullTimestamp: true, - CallerPrettyfier: func(f *runtime.Frame) (string, string) { - filename := filepath.Base(f.File) - return "", fmt.Sprintf(" | %s:%d | ", filename, f.Line) - }, - }) - log.SetReportCaller(true) - log.SetLevel(level) - - // Check required arguments - if *mountpoint == "" { - fmt.Fprintf(os.Stderr, "Error: --mount is required\n\n") - flag.Usage() - os.Exit(1) - } - - // Create filesystem - root := fusefs.NewAGFSFS(fusefs.Config{ - ServerURL: *serverURL, - CacheTTL: *cacheTTL, - Debug: *debug, - }) - - // Setup FUSE mount options - opts := &fs.Options{ - AttrTimeout: cacheTTL, - EntryTimeout: cacheTTL, - MountOptions: fuse.MountOptions{ - Name: "agfs", - FsName: "agfs", - DisableXAttrs: true, - Debug: *debug, - }, - } - - if *allowOther { - opts.MountOptions.AllowOther = true - } - - // Mount the filesystem - server, err := fs.Mount(*mountpoint, root, opts) - if err != nil { - log.Fatalf("Mount failed: %v", err) - } - - log.Infof("AGFS mounted at %s", *mountpoint) - log.Infof("Server: %s", *serverURL) - log.Infof("Cache TTL: %v", *cacheTTL) - - if level > log.DebugLevel { - log.Info("Press Ctrl+C to unmount") - } - - // Handle graceful shutdown - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-sigChan - log.Info("Unmounting...") - - // Unmount - if err := server.Unmount(); err != nil { - log.Errorf("Unmount failed: %v", err) - } - - // Close filesystem - if err := root.Close(); err != nil { - log.Errorf("Close filesystem failed: %v", err) - } - }() - - // Wait for the filesystem to be unmounted - server.Wait() - - log.Info("AGFS unmounted successfully") -} diff --git a/third_party/agfs/agfs-fuse/go.mod b/third_party/agfs/agfs-fuse/go.mod deleted file mode 100644 index c2a06bb83..000000000 --- a/third_party/agfs/agfs-fuse/go.mod +++ /dev/null @@ -1,13 +0,0 @@ -module github.com/dongxuny/agfs-fuse - -go 1.19 - -require github.com/c4pt0r/agfs/agfs-sdk/go v0.0.0-00010101000000-000000000000 - -require ( - github.com/hanwen/go-fuse/v2 v2.9.0 - github.com/sirupsen/logrus v1.9.3 - golang.org/x/sys v0.28.0 // indirect -) - -replace github.com/c4pt0r/agfs/agfs-sdk/go => ../agfs-sdk/go diff --git a/third_party/agfs/agfs-fuse/go.sum b/third_party/agfs/agfs-fuse/go.sum deleted file mode 100644 index 3171620b7..000000000 --- a/third_party/agfs/agfs-fuse/go.sum +++ /dev/null @@ -1,22 +0,0 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58= -github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= -github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/third_party/agfs/agfs-fuse/pkg/cache/cache.go b/third_party/agfs/agfs-fuse/pkg/cache/cache.go deleted file mode 100644 index 153666214..000000000 --- a/third_party/agfs/agfs-fuse/pkg/cache/cache.go +++ /dev/null @@ -1,196 +0,0 @@ -package cache - -import ( - "sync" - "time" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" -) - -// entry represents a cache entry with expiration -type entry struct { - value interface{} - expiration time.Time -} - -// isExpired checks if the entry has expired -func (e *entry) isExpired() bool { - return time.Now().After(e.expiration) -} - -// Cache is a simple TTL cache -type Cache struct { - mu sync.RWMutex - entries map[string]*entry - ttl time.Duration -} - -// NewCache creates a new cache with the given TTL -func NewCache(ttl time.Duration) *Cache { - c := &Cache{ - entries: make(map[string]*entry), - ttl: ttl, - } - - // Start cleanup goroutine - go c.cleanup() - - return c -} - -// Set stores a value in the cache -func (c *Cache) Set(key string, value interface{}) { - c.mu.Lock() - defer c.mu.Unlock() - - c.entries[key] = &entry{ - value: value, - expiration: time.Now().Add(c.ttl), - } -} - -// Get retrieves a value from the cache -func (c *Cache) Get(key string) (interface{}, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - e, ok := c.entries[key] - if !ok { - return nil, false - } - - if e.isExpired() { - return nil, false - } - - return e.value, true -} - -// Delete removes a value from the cache -func (c *Cache) Delete(key string) { - c.mu.Lock() - defer c.mu.Unlock() - - delete(c.entries, key) -} - -// DeletePrefix removes all entries with the given prefix -func (c *Cache) DeletePrefix(prefix string) { - c.mu.Lock() - defer c.mu.Unlock() - - for key := range c.entries { - if len(key) >= len(prefix) && key[:len(prefix)] == prefix { - delete(c.entries, key) - } - } -} - -// Clear removes all entries from the cache -func (c *Cache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - - c.entries = make(map[string]*entry) -} - -// cleanup periodically removes expired entries -func (c *Cache) cleanup() { - ticker := time.NewTicker(c.ttl) - defer ticker.Stop() - - for range ticker.C { - c.mu.Lock() - now := time.Now() - for key, e := range c.entries { - if now.After(e.expiration) { - delete(c.entries, key) - } - } - c.mu.Unlock() - } -} - -// MetadataCache caches file metadata -type MetadataCache struct { - cache *Cache -} - -// NewMetadataCache creates a new metadata cache -func NewMetadataCache(ttl time.Duration) *MetadataCache { - return &MetadataCache{ - cache: NewCache(ttl), - } -} - -// Get retrieves file info from cache -func (mc *MetadataCache) Get(path string) (*agfs.FileInfo, bool) { - value, ok := mc.cache.Get(path) - if !ok { - return nil, false - } - info, ok := value.(*agfs.FileInfo) - return info, ok -} - -// Set stores file info in cache -func (mc *MetadataCache) Set(path string, info *agfs.FileInfo) { - mc.cache.Set(path, info) -} - -// Invalidate removes file info from cache -func (mc *MetadataCache) Invalidate(path string) { - mc.cache.Delete(path) -} - -// InvalidatePrefix invalidates all paths with the given prefix -func (mc *MetadataCache) InvalidatePrefix(prefix string) { - mc.cache.DeletePrefix(prefix) -} - -// Clear clears all cached metadata -func (mc *MetadataCache) Clear() { - mc.cache.Clear() -} - -// DirectoryCache caches directory listings -type DirectoryCache struct { - cache *Cache -} - -// NewDirectoryCache creates a new directory cache -func NewDirectoryCache(ttl time.Duration) *DirectoryCache { - return &DirectoryCache{ - cache: NewCache(ttl), - } -} - -// Get retrieves directory listing from cache -func (dc *DirectoryCache) Get(path string) ([]agfs.FileInfo, bool) { - value, ok := dc.cache.Get(path) - if !ok { - return nil, false - } - files, ok := value.([]agfs.FileInfo) - return files, ok -} - -// Set stores directory listing in cache -func (dc *DirectoryCache) Set(path string, files []agfs.FileInfo) { - dc.cache.Set(path, files) -} - -// Invalidate removes directory listing from cache -func (dc *DirectoryCache) Invalidate(path string) { - dc.cache.Delete(path) -} - -// InvalidatePrefix invalidates all directories with the given prefix -func (dc *DirectoryCache) InvalidatePrefix(prefix string) { - dc.cache.DeletePrefix(prefix) -} - -// Clear clears all cached directories -func (dc *DirectoryCache) Clear() { - dc.cache.Clear() -} diff --git a/third_party/agfs/agfs-fuse/pkg/cache/cache_test.go b/third_party/agfs/agfs-fuse/pkg/cache/cache_test.go deleted file mode 100644 index 093abe2fe..000000000 --- a/third_party/agfs/agfs-fuse/pkg/cache/cache_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package cache - -import ( - "testing" - "time" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" -) - -func TestCacheBasicOperations(t *testing.T) { - c := NewCache(100 * time.Millisecond) - - // Test Set and Get - c.Set("key1", "value1") - value, ok := c.Get("key1") - if !ok || value != "value1" { - t.Errorf("Expected value1, got %v (ok=%v)", value, ok) - } - - // Test Get non-existent key - _, ok = c.Get("key2") - if ok { - t.Error("Expected key2 to not exist") - } - - // Test Delete - c.Delete("key1") - _, ok = c.Get("key1") - if ok { - t.Error("Expected key1 to be deleted") - } -} - -func TestCacheTTL(t *testing.T) { - c := NewCache(50 * time.Millisecond) - - c.Set("key1", "value1") - - // Should be available immediately - _, ok := c.Get("key1") - if !ok { - t.Error("Expected key1 to exist") - } - - // Wait for expiration - time.Sleep(100 * time.Millisecond) - - // Should be expired - _, ok = c.Get("key1") - if ok { - t.Error("Expected key1 to be expired") - } -} - -func TestCacheDeletePrefix(t *testing.T) { - c := NewCache(1 * time.Second) - - c.Set("/foo/bar", "1") - c.Set("/foo/baz", "2") - c.Set("/bar/qux", "3") - - c.DeletePrefix("/foo") - - // /foo/* should be deleted - _, ok := c.Get("/foo/bar") - if ok { - t.Error("Expected /foo/bar to be deleted") - } - _, ok = c.Get("/foo/baz") - if ok { - t.Error("Expected /foo/baz to be deleted") - } - - // /bar/qux should still exist - _, ok = c.Get("/bar/qux") - if !ok { - t.Error("Expected /bar/qux to exist") - } -} - -func TestMetadataCache(t *testing.T) { - mc := NewMetadataCache(1 * time.Second) - - info := &agfs.FileInfo{ - Name: "test.txt", - Size: 123, - IsDir: false, - } - - // Test Set and Get - mc.Set("/test.txt", info) - cached, ok := mc.Get("/test.txt") - if !ok || cached.Name != "test.txt" || cached.Size != 123 { - t.Errorf("Expected cached info to match, got %+v (ok=%v)", cached, ok) - } - - // Test Invalidate - mc.Invalidate("/test.txt") - _, ok = mc.Get("/test.txt") - if ok { - t.Error("Expected /test.txt to be invalidated") - } -} - -func TestDirectoryCache(t *testing.T) { - dc := NewDirectoryCache(1 * time.Second) - - files := []agfs.FileInfo{ - {Name: "file1.txt", Size: 100, IsDir: false}, - {Name: "file2.txt", Size: 200, IsDir: false}, - } - - // Test Set and Get - dc.Set("/dir", files) - cached, ok := dc.Get("/dir") - if !ok || len(cached) != 2 { - t.Errorf("Expected 2 cached files, got %d (ok=%v)", len(cached), ok) - } - - // Test Invalidate - dc.Invalidate("/dir") - _, ok = dc.Get("/dir") - if ok { - t.Error("Expected /dir to be invalidated") - } -} - -func TestCacheConcurrency(t *testing.T) { - c := NewCache(1 * time.Second) - - done := make(chan bool) - - // Writer goroutine - go func() { - for i := 0; i < 1000; i++ { - c.Set("key", i) - } - done <- true - }() - - // Reader goroutine - go func() { - for i := 0; i < 1000; i++ { - c.Get("key") - } - done <- true - }() - - // Wait for both to complete - <-done - <-done - - // If we got here without panic, concurrency is safe -} diff --git a/third_party/agfs/agfs-fuse/pkg/fusefs/file.go b/third_party/agfs/agfs-fuse/pkg/fusefs/file.go deleted file mode 100644 index db773ffbc..000000000 --- a/third_party/agfs/agfs-fuse/pkg/fusefs/file.go +++ /dev/null @@ -1,69 +0,0 @@ -package fusefs - -import ( - "context" - "syscall" - - "github.com/hanwen/go-fuse/v2/fs" - "github.com/hanwen/go-fuse/v2/fuse" -) - -// AGFSFileHandle represents an open file handle -type AGFSFileHandle struct { - node *AGFSNode - handle uint64 -} - -var _ = (fs.FileReader)((*AGFSFileHandle)(nil)) -var _ = (fs.FileWriter)((*AGFSFileHandle)(nil)) -var _ = (fs.FileFsyncer)((*AGFSFileHandle)(nil)) -var _ = (fs.FileReleaser)((*AGFSFileHandle)(nil)) -var _ = (fs.FileGetattrer)((*AGFSFileHandle)(nil)) - -// Read reads data from the file -func (fh *AGFSFileHandle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) { - data, err := fh.node.root.handles.Read(fh.handle, off, len(dest)) - if err != nil { - return nil, syscall.EIO - } - - return fuse.ReadResultData(data), 0 -} - -// Write writes data to the file -func (fh *AGFSFileHandle) Write(ctx context.Context, data []byte, off int64) (written uint32, errno syscall.Errno) { - n, err := fh.node.root.handles.Write(fh.handle, data, off) - if err != nil { - return 0, syscall.EIO - } - - // Invalidate metadata cache since file size may have changed - fh.node.root.metaCache.Invalidate(fh.node.path) - - return uint32(n), 0 -} - -// Fsync syncs file data to storage -func (fh *AGFSFileHandle) Fsync(ctx context.Context, flags uint32) syscall.Errno { - err := fh.node.root.handles.Sync(fh.handle) - if err != nil { - return syscall.EIO - } - - return 0 -} - -// Release releases the file handle -func (fh *AGFSFileHandle) Release(ctx context.Context) syscall.Errno { - err := fh.node.root.handles.Close(fh.handle) - if err != nil { - return syscall.EIO - } - - return 0 -} - -// Getattr returns file attributes -func (fh *AGFSFileHandle) Getattr(ctx context.Context, out *fuse.AttrOut) syscall.Errno { - return fh.node.Getattr(ctx, fh, out) -} diff --git a/third_party/agfs/agfs-fuse/pkg/fusefs/fs.go b/third_party/agfs/agfs-fuse/pkg/fusefs/fs.go deleted file mode 100644 index f7fa8a6b2..000000000 --- a/third_party/agfs/agfs-fuse/pkg/fusefs/fs.go +++ /dev/null @@ -1,208 +0,0 @@ -package fusefs - -import ( - "context" - "net/http" - "sync" - "syscall" - "time" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" - "github.com/dongxuny/agfs-fuse/pkg/cache" - "github.com/hanwen/go-fuse/v2/fs" - "github.com/hanwen/go-fuse/v2/fuse" -) - -// AGFSFS is the root of the FUSE file system -type AGFSFS struct { - fs.Inode - - client *agfs.Client - handles *HandleManager - metaCache *cache.MetadataCache - dirCache *cache.DirectoryCache - cacheTTL time.Duration - mu sync.RWMutex -} - -// Config contains filesystem configuration -type Config struct { - ServerURL string - CacheTTL time.Duration - Debug bool -} - -// NewAGFSFS creates a new AGFS FUSE filesystem -func NewAGFSFS(config Config) *AGFSFS { - // Use longer timeout for FUSE operations (streams may block) - httpClient := &http.Client{ - Timeout: 60 * time.Second, - } - client := agfs.NewClientWithHTTPClient(config.ServerURL, httpClient) - - return &AGFSFS{ - client: client, - handles: NewHandleManager(client), - metaCache: cache.NewMetadataCache(config.CacheTTL), - dirCache: cache.NewDirectoryCache(config.CacheTTL), - cacheTTL: config.CacheTTL, - } -} - -// Close closes the filesystem and releases resources -func (root *AGFSFS) Close() error { - // Close all open handles - if err := root.handles.CloseAll(); err != nil { - return err - } - - // Clear caches - root.metaCache.Clear() - root.dirCache.Clear() - - return nil -} - -// Statfs returns filesystem statistics -func (root *AGFSFS) Statfs(ctx context.Context, out *fuse.StatfsOut) syscall.Errno { - // Return some reasonable defaults - out.Blocks = 1024 * 1024 * 1024 // 1TB - out.Bfree = 512 * 1024 * 1024 // 512GB free - out.Bavail = 512 * 1024 * 1024 // 512GB available - out.Files = 1000000 // 1M files - out.Ffree = 500000 // 500K free inodes - out.Bsize = 4096 // 4KB block size - out.NameLen = 255 // Max filename length - out.Frsize = 4096 // Fragment size - - return 0 -} - -// invalidateCache invalidates cache for a path and its parent directory -func (root *AGFSFS) invalidateCache(path string) { - root.metaCache.Invalidate(path) - - // Invalidate parent directory listing - parent := getParentPath(path) - if parent != "" { - root.dirCache.Invalidate(parent) - } -} - -// getParentPath returns the parent directory path -func getParentPath(path string) string { - if path == "" || path == "/" { - return "" - } - - for i := len(path) - 1; i >= 0; i-- { - if path[i] == '/' { - if i == 0 { - return "/" - } - return path[:i] - } - } - - return "/" -} - -// modeToFileMode converts AGFS mode to os.FileMode -func modeToFileMode(mode uint32) uint32 { - return mode -} - -// fileModeToMode converts os.FileMode to AGFS mode -func fileModeToMode(mode uint32) uint32 { - return mode -} - -// getStableMode returns mode with file type bits for StableAttr -func getStableMode(info *agfs.FileInfo) uint32 { - mode := modeToFileMode(info.Mode) - if info.IsDir { - mode |= syscall.S_IFDIR - } else { - mode |= syscall.S_IFREG - } - return mode -} - -// Interface assertions for root node -var _ = (fs.NodeGetattrer)((*AGFSFS)(nil)) -var _ = (fs.NodeLookuper)((*AGFSFS)(nil)) -var _ = (fs.NodeReaddirer)((*AGFSFS)(nil)) - -// Getattr returns attributes for the root directory -func (root *AGFSFS) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno { - // Root is always a directory - out.Mode = 0755 | syscall.S_IFDIR - out.Size = 4096 - return 0 -} - -// Lookup looks up a child node in the root directory -func (root *AGFSFS) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { - childPath := "/" + name - - // Try cache first - var info *agfs.FileInfo - if cached, ok := root.metaCache.Get(childPath); ok { - info = cached - } else { - // Fetch from server - var err error - info, err = root.client.Stat(childPath) - if err != nil { - return nil, syscall.ENOENT - } - // Cache the result - root.metaCache.Set(childPath, info) - } - - fillAttr(&out.Attr, info) - - // Create child node - stable := fs.StableAttr{ - Mode: getStableMode(info), - } - - child := &AGFSNode{ - root: root, - path: childPath, - } - - return root.NewInode(ctx, child, stable), 0 -} - -// Readdir reads root directory contents -func (root *AGFSFS) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) { - rootPath := "/" - - // Try cache first - var files []agfs.FileInfo - if cached, ok := root.dirCache.Get(rootPath); ok { - files = cached - } else { - // Fetch from server - var err error - files, err = root.client.ReadDir(rootPath) - if err != nil { - return nil, syscall.EIO - } - // Cache the result - root.dirCache.Set(rootPath, files) - } - - // Convert to FUSE entries - entries := make([]fuse.DirEntry, 0, len(files)) - for _, f := range files { - entry := fuse.DirEntry{ - Name: f.Name, - Mode: getStableMode(&f), - } - entries = append(entries, entry) - } - - return fs.NewListDirStream(entries), 0 -} diff --git a/third_party/agfs/agfs-fuse/pkg/fusefs/handles.go b/third_party/agfs/agfs-fuse/pkg/fusefs/handles.go deleted file mode 100644 index f51ef52b3..000000000 --- a/third_party/agfs/agfs-fuse/pkg/fusefs/handles.go +++ /dev/null @@ -1,481 +0,0 @@ -package fusefs - -import ( - "context" - "errors" - "fmt" - "io" - "sync" - "sync/atomic" - "time" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" - log "github.com/sirupsen/logrus" -) - -// handleType indicates whether a handle is remote (server-side) or local (client-side fallback) -type handleType int - -const ( - handleTypeRemote handleType = iota // Server supports HandleFS - handleTypeRemoteStream // Server supports HandleFS with streaming - handleTypeLocal // Server doesn't support HandleFS, use local wrapper -) - -// handleInfo stores information about an open handle -type handleInfo struct { - htype handleType - agfsHandle int64 // For remote handles: server-side handle ID - path string - flags agfs.OpenFlag - mode uint32 - // Read buffer for local handles - caches first read to avoid multiple server requests - readBuffer []byte - // Stream reader for streaming handles - streamReader io.ReadCloser - // Buffer for stream reads (sliding window to prevent memory leak) - streamBuffer []byte - streamBase int64 // Base offset of streamBuffer[0] in the logical stream - // Context for cancelling background goroutines - streamCtx context.Context - streamCancel context.CancelFunc -} - -// HandleManager manages the mapping between FUSE handles and AGFS handles -type HandleManager struct { - client *agfs.Client - mu sync.RWMutex - // Map FUSE handle ID to handle info - handles map[uint64]*handleInfo - // Counter for generating unique FUSE handle IDs - nextHandle uint64 -} - -// NewHandleManager creates a new handle manager -func NewHandleManager(client *agfs.Client) *HandleManager { - return &HandleManager{ - client: client, - handles: make(map[uint64]*handleInfo), - nextHandle: 1, - } -} - -// Open opens a file and returns a FUSE handle ID -// If the server supports HandleFS, it uses server-side handles -// Otherwise, it falls back to local handle management -func (hm *HandleManager) Open(path string, flags agfs.OpenFlag, mode uint32) (uint64, error) { - // Try to open handle on server first - agfsHandle, err := hm.client.OpenHandle(path, flags, mode) - - // Generate FUSE handle ID - fuseHandle := atomic.AddUint64(&hm.nextHandle, 1) - - hm.mu.Lock() - defer hm.mu.Unlock() - - if err != nil { - // Check if error is because HandleFS is not supported - if errors.Is(err, agfs.ErrNotSupported) { - // Fall back to local handle management - log.Debugf("HandleFS not supported for %s, using local handle", path) - hm.handles[fuseHandle] = &handleInfo{ - htype: handleTypeLocal, - path: path, - flags: flags, - mode: mode, - } - return fuseHandle, nil - } - log.Debugf("Failed to open handle for %s: %v", path, err) - return 0, fmt.Errorf("failed to open handle: %w", err) - } - - log.Debugf("Opened remote handle for %s (handle=%d)", path, agfsHandle) - - // Try to open streaming connection for read handles - if flags&agfs.OpenFlagWriteOnly == 0 { - streamReader, streamErr := hm.client.ReadHandleStream(agfsHandle) - if streamErr == nil { - ctx, cancel := context.WithCancel(context.Background()) - log.Debugf("Opened stream for handle %d on %s", agfsHandle, path) - hm.handles[fuseHandle] = &handleInfo{ - htype: handleTypeRemoteStream, - agfsHandle: agfsHandle, - path: path, - flags: flags, - mode: mode, - streamReader: streamReader, - streamCtx: ctx, - streamCancel: cancel, - } - return fuseHandle, nil - } - log.Debugf("Failed to open stream for %s, using regular handle: %v", path, streamErr) - } - - // Server supports HandleFS but not streaming (or write handle) - hm.handles[fuseHandle] = &handleInfo{ - htype: handleTypeRemote, - agfsHandle: agfsHandle, - path: path, - flags: flags, - mode: mode, - } - - return fuseHandle, nil -} - -// Close closes a handle -func (hm *HandleManager) Close(fuseHandle uint64) error { - hm.mu.Lock() - info, ok := hm.handles[fuseHandle] - if !ok { - hm.mu.Unlock() - return fmt.Errorf("handle %d not found", fuseHandle) - } - delete(hm.handles, fuseHandle) - hm.mu.Unlock() - - // Cancel context to stop any background goroutines - if info.streamCancel != nil { - info.streamCancel() - } - - // Close stream reader if present - if info.streamReader != nil { - info.streamReader.Close() - } - - // Clear buffer to release memory - info.streamBuffer = nil - - // Remote handles: close on server - if info.htype == handleTypeRemote || info.htype == handleTypeRemoteStream { - if err := hm.client.CloseHandle(info.agfsHandle); err != nil { - return fmt.Errorf("failed to close handle: %w", err) - } - return nil - } - - // Local handles: nothing to do on close since writes are sent immediately - return nil -} - -// Read reads data from a handle -func (hm *HandleManager) Read(fuseHandle uint64, offset int64, size int) ([]byte, error) { - hm.mu.Lock() - info, ok := hm.handles[fuseHandle] - if !ok { - hm.mu.Unlock() - return nil, fmt.Errorf("handle %d not found", fuseHandle) - } - - // Streaming handle: read from stream - if info.htype == handleTypeRemoteStream && info.streamReader != nil { - return hm.readFromStream(info, offset, size) - } - - if info.htype == handleTypeRemote { - hm.mu.Unlock() - // Use server-side handle - data, err := hm.client.ReadHandle(info.agfsHandle, offset, size) - if err != nil { - return nil, fmt.Errorf("failed to read handle: %w", err) - } - return data, nil - } - - // Local handle: cache the first read and return from cache for subsequent reads - // This is critical for special filesystems like queuefs where each read - // should be an independent atomic operation (e.g., each read from dequeue - // should consume only one message, not multiple) - if info.readBuffer == nil { - // First read: fetch ALL data from server and cache (use size=-1 to read all) - path := info.path - hm.mu.Unlock() - - data, err := hm.client.Read(path, 0, -1) // Read all data - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) - } - - // Cache the data - hm.mu.Lock() - // Re-check if handle still exists - info, ok = hm.handles[fuseHandle] - if ok { - info.readBuffer = data - } - hm.mu.Unlock() - - // Return requested portion - if offset >= int64(len(data)) { - return []byte{}, nil - } - end := offset + int64(size) - if end > int64(len(data)) { - end = int64(len(data)) - } - return data[offset:end], nil - } - - // Return from cache or empty for subsequent reads - if info.readBuffer != nil { - if offset >= int64(len(info.readBuffer)) { - hm.mu.Unlock() - return []byte{}, nil // EOF - } - end := offset + int64(size) - if end > int64(len(info.readBuffer)) { - end = int64(len(info.readBuffer)) - } - result := info.readBuffer[offset:end] - hm.mu.Unlock() - return result, nil - } - - // No cached data and offset > 0, return empty - hm.mu.Unlock() - return []byte{}, nil -} - -// streamReadResult holds the result of a stream read operation -type streamReadResult struct { - n int - err error - buf []byte -} - -// Maximum buffer size before trimming (1MB sliding window) -const maxStreamBufferSize = 1 * 1024 * 1024 - -// readFromStream reads data from a streaming handle -// Must be called with hm.mu held -// Uses sliding window buffer to prevent memory leak -func (hm *HandleManager) readFromStream(info *handleInfo, offset int64, size int) ([]byte, error) { - // Convert absolute offset to relative offset in buffer - relOffset := offset - info.streamBase - - // Fast path: if we already have data at the requested offset, return immediately - if relOffset >= 0 && relOffset < int64(len(info.streamBuffer)) { - end := relOffset + int64(size) - if end > int64(len(info.streamBuffer)) { - end = int64(len(info.streamBuffer)) - } - result := make([]byte, end-relOffset) - copy(result, info.streamBuffer[relOffset:end]) - - // Trim old data if buffer is too large (sliding window) - hm.trimStreamBuffer(info, offset+int64(size)) - - hm.mu.Unlock() - return result, nil - } - - // Check if requested offset is before our buffer (data already trimmed) - if relOffset < 0 { - hm.mu.Unlock() - log.Warnf("Requested offset %d is before buffer base %d (data already trimmed)", offset, info.streamBase) - return []byte{}, nil - } - - // No data at offset yet, need to read from stream - hm.mu.Unlock() - - // Use context for cancellation - ctx := info.streamCtx - if ctx == nil { - ctx = context.Background() - } - - readTimeout := 5 * time.Second - buf := make([]byte, 64*1024) // 64KB chunks - resultCh := make(chan streamReadResult, 1) - - go func() { - n, err := info.streamReader.Read(buf) - select { - case resultCh <- streamReadResult{n: n, err: err, buf: buf}: - case <-ctx.Done(): - // Context cancelled, goroutine exits cleanly - } - }() - - var n int - var err error - var readBuf []byte - select { - case result := <-resultCh: - n = result.n - err = result.err - readBuf = result.buf - case <-time.After(readTimeout): - // Timeout - no data available - return []byte{}, nil - case <-ctx.Done(): - // Handle closed - return []byte{}, nil - } - - hm.mu.Lock() - if n > 0 { - info.streamBuffer = append(info.streamBuffer, readBuf[:n]...) - } - - if err != nil && err != io.EOF { - hm.mu.Unlock() - return nil, fmt.Errorf("failed to read from stream: %w", err) - } - - // Recalculate relative offset after potential buffer changes - relOffset = offset - info.streamBase - - // Return whatever data we have at the requested offset - if relOffset < 0 || relOffset >= int64(len(info.streamBuffer)) { - hm.mu.Unlock() - return []byte{}, nil // EOF or no data at this offset - } - - end := relOffset + int64(size) - if end > int64(len(info.streamBuffer)) { - end = int64(len(info.streamBuffer)) - } - - result := make([]byte, end-relOffset) - copy(result, info.streamBuffer[relOffset:end]) - - // Trim old data if buffer is too large - hm.trimStreamBuffer(info, offset+int64(size)) - - hm.mu.Unlock() - return result, nil -} - -// trimStreamBuffer removes old data from the buffer to prevent memory leak -// Must be called with hm.mu held -func (hm *HandleManager) trimStreamBuffer(info *handleInfo, consumedUpTo int64) { - if len(info.streamBuffer) <= maxStreamBufferSize { - return - } - - // Keep only data after the consumed position (with some margin) - trimPoint := consumedUpTo - info.streamBase - if trimPoint <= 0 { - return - } - - // Keep at least 64KB of already-read data for potential re-reads - margin := int64(64 * 1024) - if trimPoint > margin { - trimPoint -= margin - } else { - trimPoint = 0 - } - - if trimPoint > 0 && trimPoint < int64(len(info.streamBuffer)) { - // Trim the buffer - newBuffer := make([]byte, int64(len(info.streamBuffer))-trimPoint) - copy(newBuffer, info.streamBuffer[trimPoint:]) - info.streamBuffer = newBuffer - info.streamBase += trimPoint - log.Debugf("Trimmed stream buffer: new base=%d, new size=%d", info.streamBase, len(info.streamBuffer)) - } -} - -// Write writes data to a handle -func (hm *HandleManager) Write(fuseHandle uint64, data []byte, offset int64) (int, error) { - hm.mu.Lock() - info, ok := hm.handles[fuseHandle] - if !ok { - hm.mu.Unlock() - return 0, fmt.Errorf("handle %d not found", fuseHandle) - } - - if info.htype == handleTypeRemote { - hm.mu.Unlock() - // Use server-side handle (write directly) - written, err := hm.client.WriteHandle(info.agfsHandle, data, offset) - if err != nil { - return 0, fmt.Errorf("failed to write handle: %w", err) - } - return written, nil - } - - // Local handle: send data directly to server for each write - // This is critical for special filesystems like queuefs where each write - // should be an independent atomic operation (e.g., each write to enqueue - // should create a separate queue message) - path := info.path - hm.mu.Unlock() - - // Send directly to server - _, err := hm.client.Write(path, data) - if err != nil { - return 0, fmt.Errorf("failed to write to server: %w", err) - } - - return len(data), nil -} - -// Sync syncs a handle -func (hm *HandleManager) Sync(fuseHandle uint64) error { - hm.mu.Lock() - info, ok := hm.handles[fuseHandle] - if !ok { - hm.mu.Unlock() - return fmt.Errorf("handle %d not found", fuseHandle) - } - - // Remote handles: sync on server - if info.htype == handleTypeRemote { - hm.mu.Unlock() - if err := hm.client.SyncHandle(info.agfsHandle); err != nil { - return fmt.Errorf("failed to sync handle: %w", err) - } - return nil - } - - // Local handles: nothing to sync since writes are sent immediately - hm.mu.Unlock() - return nil -} - -// CloseAll closes all open handles -func (hm *HandleManager) CloseAll() error { - hm.mu.Lock() - handles := make(map[uint64]*handleInfo) - for k, v := range hm.handles { - handles[k] = v - } - hm.handles = make(map[uint64]*handleInfo) - hm.mu.Unlock() - - var lastErr error - for _, info := range handles { - // Cancel context to stop background goroutines - if info.streamCancel != nil { - info.streamCancel() - } - // Close stream reader if present - if info.streamReader != nil { - info.streamReader.Close() - } - // Clear buffer to release memory - info.streamBuffer = nil - if info.htype == handleTypeRemote || info.htype == handleTypeRemoteStream { - if err := hm.client.CloseHandle(info.agfsHandle); err != nil { - lastErr = err - } - } - } - - return lastErr -} - -// Count returns the number of open handles -func (hm *HandleManager) Count() int { - hm.mu.RLock() - defer hm.mu.RUnlock() - return len(hm.handles) -} - diff --git a/third_party/agfs/agfs-fuse/pkg/fusefs/handles_test.go b/third_party/agfs/agfs-fuse/pkg/fusefs/handles_test.go deleted file mode 100644 index c20bcf3b7..000000000 --- a/third_party/agfs/agfs-fuse/pkg/fusefs/handles_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package fusefs - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" -) - -func TestHandleManagerBasicOperations(t *testing.T) { - // Note: This is a unit test that doesn't require a running server - // We're testing the handle manager's mapping logic - - client := agfs.NewClient("http://localhost:8080") - hm := NewHandleManager(client) - - // Test initial state - if count := hm.Count(); count != 0 { - t.Errorf("Expected 0 handles, got %d", count) - } - - // Note: We can't actually test Open/Close without a running server - // Those would be integration tests -} - -func TestHandleManagerConcurrency(t *testing.T) { - client := agfs.NewClient("http://localhost:8080") - hm := NewHandleManager(client) - - // Test concurrent access to handle map (shouldn't panic) - done := make(chan bool, 2) - - go func() { - for i := 0; i < 100; i++ { - hm.Count() - } - done <- true - }() - - go func() { - for i := 0; i < 100; i++ { - hm.Count() - } - done <- true - }() - - <-done - <-done - - // If we got here without panic, concurrency is safe -} - -func TestHandleManager_OpenHandleNotSupportedFallback(t *testing.T) { - // Create a test HTTP server that returns 501 for OpenHandle - testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/v1/handles/open" { - w.WriteHeader(http.StatusNotImplemented) - // Optionally, write an error JSON (agfs.Client expects it but will map 501 first) - json.NewEncoder(w).Encode(agfs.ErrorResponse{Error: "handlefs not supported"}) - return - } - // For other paths, return 200 OK (or mock as needed) - w.WriteHeader(http.StatusOK) - })) - defer testServer.Close() - - // Create an agfs.Client configured to talk to our test server - client := agfs.NewClient(testServer.URL) - hm := NewHandleManager(client) - - // Attempt to open a handle - fuseHandle, err := hm.Open("/test/path", 0, 0) - if err != nil { - t.Fatalf("Expected nil error during Open, but got: %v", err) - } - - // Verify that a local handle was created - if count := hm.Count(); count != 1 { - t.Errorf("Expected 1 handle after fallback, got %d", count) - } - - info, ok := hm.handles[fuseHandle] - if !ok { - t.Fatalf("Handle %d not found in manager", fuseHandle) - } - if info.htype != handleTypeLocal { - t.Errorf("Expected handle type to be local (%v), got %v", handleTypeLocal, info.htype) - } - - // Test closing the local handle - err = hm.Close(fuseHandle) - if err != nil { - t.Errorf("Error closing local handle: %v", err) - } - if count := hm.Count(); count != 0 { - t.Errorf("Expected 0 handles after close, got %d", count) - } -} diff --git a/third_party/agfs/agfs-fuse/pkg/fusefs/node.go b/third_party/agfs/agfs-fuse/pkg/fusefs/node.go deleted file mode 100644 index 109acd449..000000000 --- a/third_party/agfs/agfs-fuse/pkg/fusefs/node.go +++ /dev/null @@ -1,341 +0,0 @@ -package fusefs - -import ( - "context" - "path/filepath" - "syscall" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" - "github.com/hanwen/go-fuse/v2/fs" - "github.com/hanwen/go-fuse/v2/fuse" -) - -// AGFSNode represents a file or directory node -type AGFSNode struct { - fs.Inode - - root *AGFSFS - path string -} - -var _ = (fs.NodeGetattrer)((*AGFSNode)(nil)) -var _ = (fs.NodeLookuper)((*AGFSNode)(nil)) -var _ = (fs.NodeReaddirer)((*AGFSNode)(nil)) -var _ = (fs.NodeMkdirer)((*AGFSNode)(nil)) -var _ = (fs.NodeRmdirer)((*AGFSNode)(nil)) -var _ = (fs.NodeUnlinker)((*AGFSNode)(nil)) -var _ = (fs.NodeRenamer)((*AGFSNode)(nil)) -var _ = (fs.NodeCreater)((*AGFSNode)(nil)) -var _ = (fs.NodeOpener)((*AGFSNode)(nil)) -var _ = (fs.NodeSetattrer)((*AGFSNode)(nil)) - -// Getattr returns file attributes -func (n *AGFSNode) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno { - // Try cache first - if cached, ok := n.root.metaCache.Get(n.path); ok { - fillAttr(&out.Attr, cached) - out.SetTimeout(n.root.cacheTTL) - return 0 - } - - // Fetch from server - info, err := n.root.client.Stat(n.path) - if err != nil { - return syscall.ENOENT - } - - // Cache the result - n.root.metaCache.Set(n.path, info) - - fillAttr(&out.Attr, info) - - return 0 -} - -// Lookup looks up a child node -func (n *AGFSNode) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { - childPath := filepath.Join(n.path, name) - - // Try cache first - var info *agfs.FileInfo - if cached, ok := n.root.metaCache.Get(childPath); ok { - info = cached - } else { - // Fetch from server - var err error - info, err = n.root.client.Stat(childPath) - if err != nil { - return nil, syscall.ENOENT - } - // Cache the result - n.root.metaCache.Set(childPath, info) - } - - fillAttr(&out.Attr, info) - - // Create child node - stable := fs.StableAttr{ - Mode: getStableMode(info), - } - - child := &AGFSNode{ - root: n.root, - path: childPath, - } - - return n.NewInode(ctx, child, stable), 0 -} - -// Readdir reads directory contents -func (n *AGFSNode) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) { - // Try cache first - var files []agfs.FileInfo - if cached, ok := n.root.dirCache.Get(n.path); ok { - files = cached - } else { - // Fetch from server - var err error - files, err = n.root.client.ReadDir(n.path) - if err != nil { - return nil, syscall.EIO - } - // Cache the result - n.root.dirCache.Set(n.path, files) - } - - // Convert to FUSE entries - entries := make([]fuse.DirEntry, 0, len(files)) - for _, f := range files { - entry := fuse.DirEntry{ - Name: f.Name, - Mode: getStableMode(&f), - } - entries = append(entries, entry) - } - - return fs.NewListDirStream(entries), 0 -} - -// Mkdir creates a directory -func (n *AGFSNode) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { - childPath := filepath.Join(n.path, name) - - err := n.root.client.Mkdir(childPath, mode) - if err != nil { - return nil, syscall.EIO - } - - // Invalidate caches - n.root.invalidateCache(childPath) - - // Fetch new file info - info, err := n.root.client.Stat(childPath) - if err != nil { - return nil, syscall.EIO - } - - fillAttr(&out.Attr, info) - - stable := fs.StableAttr{ - Mode: getStableMode(info), - } - - child := &AGFSNode{ - root: n.root, - path: childPath, - } - - return n.NewInode(ctx, child, stable), 0 -} - -// Rmdir removes a directory -func (n *AGFSNode) Rmdir(ctx context.Context, name string) syscall.Errno { - childPath := filepath.Join(n.path, name) - - err := n.root.client.Remove(childPath) - if err != nil { - return syscall.EIO - } - - // Invalidate caches - n.root.invalidateCache(childPath) - - return 0 -} - -// Unlink removes a file -func (n *AGFSNode) Unlink(ctx context.Context, name string) syscall.Errno { - childPath := filepath.Join(n.path, name) - - err := n.root.client.Remove(childPath) - if err != nil { - return syscall.EIO - } - - // Invalidate caches - n.root.invalidateCache(childPath) - - return 0 -} - -// Rename renames a file or directory -func (n *AGFSNode) Rename(ctx context.Context, name string, newParent fs.InodeEmbedder, newName string, flags uint32) syscall.Errno { - oldPath := filepath.Join(n.path, name) - - // Get new parent path - newParentNode, ok := newParent.(*AGFSNode) - if !ok { - return syscall.EINVAL - } - newPath := filepath.Join(newParentNode.path, newName) - - err := n.root.client.Rename(oldPath, newPath) - if err != nil { - return syscall.EIO - } - - // Invalidate caches - n.root.invalidateCache(oldPath) - n.root.invalidateCache(newPath) - - return 0 -} - -// Create creates a new file -func (n *AGFSNode) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) { - childPath := filepath.Join(n.path, name) - - // Create the file - err := n.root.client.Create(childPath) - if err != nil { - return nil, nil, 0, syscall.EIO - } - - // Invalidate caches - n.root.invalidateCache(childPath) - - // Open the file with the requested flags - openFlags := convertOpenFlags(flags) - fuseHandle, err := n.root.handles.Open(childPath, openFlags, mode) - if err != nil { - return nil, nil, 0, syscall.EIO - } - - // Fetch file info - info, err := n.root.client.Stat(childPath) - if err != nil { - n.root.handles.Close(fuseHandle) - return nil, nil, 0, syscall.EIO - } - - fillAttr(&out.Attr, info) - - stable := fs.StableAttr{ - Mode: getStableMode(info), - } - - child := &AGFSNode{ - root: n.root, - path: childPath, - } - - childInode := n.NewInode(ctx, child, stable) - - fileHandle := &AGFSFileHandle{ - node: child, - handle: fuseHandle, - } - - return childInode, fileHandle, fuse.FOPEN_DIRECT_IO, 0 -} - -// Open opens a file -func (n *AGFSNode) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) { - openFlags := convertOpenFlags(flags) - fuseHandle, err := n.root.handles.Open(n.path, openFlags, 0644) - if err != nil { - return nil, 0, syscall.EIO - } - - fileHandle := &AGFSFileHandle{ - node: n, - handle: fuseHandle, - } - - // Use DIRECT_IO for files with unknown/dynamic size (like queuefs control files) - // This tells FUSE to ignore cached size and always read from the filesystem - return fileHandle, fuse.FOPEN_DIRECT_IO, 0 -} - -// Setattr sets file attributes -func (n *AGFSNode) Setattr(ctx context.Context, f fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno { - // Only support chmod for now - if mode, ok := in.GetMode(); ok { - err := n.root.client.Chmod(n.path, mode) - if err != nil { - return syscall.EIO - } - - // Invalidate cache - n.root.metaCache.Invalidate(n.path) - } - - // Return updated attributes - return n.Getattr(ctx, f, out) -} - -// fillAttr fills FUSE attributes from AGFS FileInfo -func fillAttr(out *fuse.Attr, info *agfs.FileInfo) { - out.Mode = modeToFileMode(info.Mode) - out.Size = uint64(info.Size) - out.Mtime = uint64(info.ModTime.Unix()) - out.Mtimensec = uint32(info.ModTime.Nanosecond()) - out.Atime = out.Mtime - out.Atimensec = out.Mtimensec - out.Ctime = out.Mtime - out.Ctimensec = out.Mtimensec - - // Set owner to current user so they have proper read/write permissions - out.Uid = uint32(syscall.Getuid()) - out.Gid = uint32(syscall.Getgid()) - - if info.IsDir { - out.Mode |= syscall.S_IFDIR - } else { - out.Mode |= syscall.S_IFREG - } -} - -// convertOpenFlags converts FUSE open flags to AGFS OpenFlag -func convertOpenFlags(flags uint32) agfs.OpenFlag { - accessMode := flags & syscall.O_ACCMODE - - var openFlag agfs.OpenFlag - - switch accessMode { - case syscall.O_RDONLY: - openFlag = agfs.OpenFlagReadOnly - case syscall.O_WRONLY: - openFlag = agfs.OpenFlagWriteOnly - case syscall.O_RDWR: - openFlag = agfs.OpenFlagReadWrite - } - - if flags&syscall.O_APPEND != 0 { - openFlag |= agfs.OpenFlagAppend - } - if flags&syscall.O_CREAT != 0 { - openFlag |= agfs.OpenFlagCreate - } - if flags&syscall.O_EXCL != 0 { - openFlag |= agfs.OpenFlagExclusive - } - if flags&syscall.O_TRUNC != 0 { - openFlag |= agfs.OpenFlagTruncate - } - if flags&syscall.O_SYNC != 0 { - openFlag |= agfs.OpenFlagSync - } - - return openFlag -} diff --git a/third_party/agfs/agfs-fuse/pkg/version/version.go b/third_party/agfs/agfs-fuse/pkg/version/version.go deleted file mode 100644 index a4c85085f..000000000 --- a/third_party/agfs/agfs-fuse/pkg/version/version.go +++ /dev/null @@ -1,18 +0,0 @@ -package version - -// Version information -var ( - Version = "dev" - GitCommit = "unknown" - BuildTime = "unknown" -) - -// GetVersion returns the version string -func GetVersion() string { - return Version -} - -// GetFullVersion returns the full version string with git commit and build time -func GetFullVersion() string { - return Version + " (" + GitCommit + ", built " + BuildTime + ")" -} diff --git a/third_party/agfs/agfs-mcp/.gitignore b/third_party/agfs/agfs-mcp/.gitignore deleted file mode 100644 index 00042b27a..000000000 --- a/third_party/agfs/agfs-mcp/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual environments -.venv/ -venv/ -ENV/ -env/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# uv diff --git a/third_party/agfs/agfs-mcp/.mcp.json b/third_party/agfs/agfs-mcp/.mcp.json deleted file mode 100644 index a2f0e7f4e..000000000 --- a/third_party/agfs/agfs-mcp/.mcp.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "mcpServers": { - "agfs": { - "command": "uv", - "args": [ - "--directory", - ".", - "run", - "agfs-mcp" - ], - "env": { - "AGFS_SERVER_URL": "http://localhost:8080" - } - } - } -} diff --git a/third_party/agfs/agfs-mcp/README.md b/third_party/agfs/agfs-mcp/README.md deleted file mode 100644 index d65bf0a69..000000000 --- a/third_party/agfs/agfs-mcp/README.md +++ /dev/null @@ -1,277 +0,0 @@ -# AGFS MCP Server - -Model Context Protocol (MCP) server for AGFS (Plugin-based File System), enabling AI models to interact with AGFS through standardized tools. - -## Overview - -AGFS MCP Server exposes AGFS file system operations as MCP tools, allowing AI assistants like Claude to read, write, and manage files in a AGFS server through a standardized protocol. - -## Features - -- **File Operations**: Read, write, create, delete, copy, move files -- **Directory Operations**: List contents, create, remove, copy directories -- **Transfer Operations**: Upload from local filesystem to AGFS, download from AGFS to local filesystem -- **Search**: Grep with regex pattern matching -- **Plugin Management**: Mount/unmount plugins, list mounts -- **Health Monitoring**: Check server status -- **Notifications**: Send messages via QueueFS - -## Installation - -### Using uv (recommended) - -```bash -# Install from local directory -uv pip install -e . - -# Or if installing as dependency -uv pip install agfs-mcp -``` - -### Using pip - -```bash -pip install -e . -``` - -## Usage - -### Starting the Server - -The MCP server runs as a stdio server that communicates via JSON-RPC: - -```bash -# Using default AGFS server (http://localhost:8080) -agfs-mcp - -# Using custom AGFS server URL -AGFS_SERVER_URL=http://myserver:8080 agfs-mcp -``` - -### Configuration with Claude Desktop - -Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): - -```json -{ - "mcpServers": { - "agfs": { - "command": "agfs-mcp", - "env": { - "AGFS_SERVER_URL": "http://localhost:8080" - } - } - } -} -``` - -Or if using uv: - -```json -{ - "mcpServers": { - "agfs": { - "command": "uvx", - "args": ["--from", "/path/to/agfs-mcp", "agfs-mcp"], - "env": { - "AGFS_SERVER_URL": "http://localhost:8080" - } - } - } -} -``` - -### Available Tools - -Once configured, the following tools are available to AI assistants: - -#### File Operations - -- `agfs_cat` - Read file content - ``` - path: File path to read - offset: Starting offset (optional, default: 0) - size: Bytes to read (optional, default: -1 for all) - ``` - -- `agfs_write` - Write content to file - ``` - path: File path to write - content: Content to write - ``` - -- `agfs_rm` - Remove file or directory - ``` - path: Path to remove - recursive: Remove recursively (optional, default: false) - ``` - -- `agfs_stat` - Get file/directory information - ``` - path: Path to get info about - ``` - -- `agfs_mv` - Move or rename file/directory - ``` - old_path: Source path - new_path: Destination path - ``` - -- `agfs_cp` - Copy file or directory within AGFS - ``` - src: Source path in AGFS - dst: Destination path in AGFS - recursive: Copy directories recursively (optional, default: false) - stream: Use streaming for large files (optional, default: false) - ``` - -- `agfs_upload` - Upload file or directory from local filesystem to AGFS - ``` - local_path: Path to local file or directory - remote_path: Destination path in AGFS - recursive: Upload directories recursively (optional, default: false) - stream: Use streaming for large files (optional, default: false) - ``` - -- `agfs_download` - Download file or directory from AGFS to local filesystem - ``` - remote_path: Path in AGFS - local_path: Destination path on local filesystem - recursive: Download directories recursively (optional, default: false) - stream: Use streaming for large files (optional, default: false) - ``` - -#### Directory Operations - -- `agfs_ls` - List directory contents - ``` - path: Directory path (optional, default: /) - ``` - -- `agfs_mkdir` - Create directory - ``` - path: Directory path to create - mode: Permissions mode (optional, default: 755) - ``` - -#### Search Operations - -- `agfs_grep` - Search for pattern in files - ``` - path: Path to search in - pattern: Regular expression pattern - recursive: Search recursively (optional, default: false) - case_insensitive: Case-insensitive search (optional, default: false) - ``` - -#### Plugin Management - -- `agfs_mounts` - List all mounted plugins - -- `agfs_mount` - Mount a plugin - ``` - fstype: Filesystem type (e.g., 'sqlfs', 'memfs', 's3fs') - path: Mount path - config: Plugin configuration (optional) - ``` - -- `agfs_unmount` - Unmount a plugin - ``` - path: Mount path to unmount - ``` - -#### Health Check - -- `agfs_health` - Check AGFS server health status - -#### Notification (QueueFS) - -- `agfs_notify` - Send notification message via QueueFS - ``` - queuefs_root: Root path of QueueFS (optional, default: /queuefs) - to: Target queue name (receiver) - from: Source queue name (sender) - data: Message data to send - ``` - Automatically creates sender and receiver queues if they don't exist. - -## Example Usage with AI - -Once configured, you can ask Claude (or other MCP-compatible AI assistants) to perform operations like: - -- "List all files in the /data directory on AGFS" -- "Read the contents of /config/settings.json from AGFS" -- "Create a new directory called /logs/2024 in AGFS" -- "Copy /data/file.txt to /backup/file.txt in AGFS" -- "Upload my local file /tmp/report.pdf to /documents/report.pdf in AGFS" -- "Download /logs/app.log from AGFS to my local /tmp/app.log" -- "Copy the entire /data directory to /backup/data recursively in AGFS" -- "Search for 'error' in all files under /logs recursively" -- "Show me all mounted plugins in AGFS" -- "Mount a new memfs plugin at /tmp/cache" -- "Send a notification from 'service-a' to 'service-b' with message 'task completed'" - -The AI will use the appropriate MCP tools to interact with your AGFS server. - -## Environment Variables - -- `AGFS_SERVER_URL`: AGFS server URL (default: `http://localhost:8080`) - -## Requirements - -- Python >= 3.10 -- AGFS Server running and accessible -- pyagfs SDK -- mcp >= 0.9.0 - -## Development - -### Setup - -```bash -# Clone and install in development mode -git clone -cd agfs-mcp -uv pip install -e . -``` - -### Testing - -Start a AGFS server first, then: - -```bash -# Test the MCP server manually -echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | agfs-mcp -``` - -## Architecture - -``` -┌─────────────────┐ -│ AI Assistant │ -│ (e.g. Claude) │ -└────────┬────────┘ - │ MCP Protocol (JSON-RPC over stdio) - │ -┌────────▼────────┐ -│ AGFS MCP Server │ -│ (agfs-mcp) │ -└────────┬────────┘ - │ HTTP API - │ -┌────────▼────────┐ -│ AGFS Server │ -│ (Plugin-based │ -│ File System) │ -└─────────────────┘ -``` - -## License - -See LICENSE file for details. - -## Related Projects - -- [AGFS](https://github.com/c4pt0r/agfs) - Plugin-based File System -- [pyagfs](../agfs-sdk/python) - AGFS Python SDK -- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP Specification diff --git a/third_party/agfs/agfs-mcp/demos/hackernews_research.py b/third_party/agfs/agfs-mcp/demos/hackernews_research.py deleted file mode 100755 index 405b2fc9c..000000000 --- a/third_party/agfs/agfs-mcp/demos/hackernews_research.py +++ /dev/null @@ -1,527 +0,0 @@ -#!/usr/bin/env python3 -""" -HackerNews Research - Fetch top HackerNews stories, distribute to agents for summarization, -and compile a comprehensive report -""" - -import argparse -import json -import sys -import time -import uuid -from datetime import datetime -from typing import Any, Dict, List, Optional - -import requests -from bs4 import BeautifulSoup -from pyagfs import AGFSClient - - -def fetch_hackernews_top_stories(count: int = 10) -> List[Dict[str, Any]]: - """ - Fetch top stories from HackerNews - - Args: - count: Number of stories to fetch (default: 10) - - Returns: - List of story dictionaries with title, url, score, etc. - """ - print(f"\n{'=' * 80}") - print(f"🔍 FETCHING TOP {count} HACKERNEWS STORIES") - print(f"{'=' * 80}\n") - - try: - # Fetch top story IDs from HackerNews API - response = requests.get( - "https://hacker-news.firebaseio.com/v0/topstories.json", timeout=10 - ) - response.raise_for_status() - story_ids = response.json()[:count] - - stories = [] - for i, story_id in enumerate(story_ids, 1): - try: - # Fetch story details - story_response = requests.get( - f"https://hacker-news.firebaseio.com/v0/item/{story_id}.json", - timeout=10, - ) - story_response.raise_for_status() - story = story_response.json() - - if story and "url" in story: - stories.append( - { - "id": story_id, - "title": story.get("title", "No title"), - "url": story.get("url", ""), - "score": story.get("score", 0), - "by": story.get("by", "unknown"), - "time": story.get("time", 0), - "descendants": story.get("descendants", 0), - } - ) - - print(f"✅ [{i}/{count}] {story.get('title', 'No title')}") - print(f" URL: {story.get('url', 'N/A')}") - print( - f" Score: {story.get('score', 0)} | " - f"Comments: {story.get('descendants', 0)}\n" - ) - - except Exception as e: - print(f"⚠️ [{i}/{count}] Failed to fetch story {story_id}: {e}\n") - continue - - print(f"{'=' * 80}") - print(f"✅ Successfully fetched {len(stories)} stories") - print(f"{'=' * 80}\n") - - return stories - - except Exception as e: - print(f"❌ Error fetching HackerNews stories: {e}") - return [] - - -def distribute_stories_to_agents( - stories: List[Dict[str, Any]], - agent_names: List[str], - task_id: str, - results_path: str, - queue_prefix: str = "/queuefs", - agfs_api_url: Optional[str] = None, -) -> Dict[str, int]: - """ - Distribute stories among agents for parallel processing - - Args: - stories: List of story dictionaries - agent_names: List of agent names - task_id: Task ID for this research job - results_path: S3FS path for results - queue_prefix: Queue path prefix - agfs_api_url: AGFS API URL - - Returns: - Dictionary mapping agent names to number of stories assigned - """ - print(f"\n{'=' * 80}") - print(f"📡 DISTRIBUTING {len(stories)} STORIES TO {len(agent_names)} AGENTS") - print(f"{'=' * 80}\n") - - # Distribute stories evenly among agents - stories_per_agent = {} - for i, story in enumerate(stories): - agent_idx = i % len(agent_names) - agent_name = agent_names[agent_idx] - - if agent_name not in stories_per_agent: - stories_per_agent[agent_name] = [] - - stories_per_agent[agent_name].append(story) - - # Send tasks to each agent - assignment = {} - for agent_name, agent_stories in stories_per_agent.items(): - # Build task prompt - task_prompt = f"""HackerNews Research Task ID: {task_id} -Agent: {agent_name} - -You have been assigned {len(agent_stories)} HackerNews articles to analyze and summarize. - -STORIES TO ANALYZE: -""" - for idx, story in enumerate(agent_stories, 1): - task_prompt += f""" -{idx}. {story["title"]} - URL: {story["url"]} - Score: {story["score"]} | Author: {story["by"]} | Comments: {story["descendants"]} -""" - - task_prompt += f""" - -INSTRUCTIONS: -1. For each story URL, fetch and read the content -2. Create a comprehensive summary including: - - Main topic and key points - - Technical insights (if applicable) - - Significance and implications - - Your analysis and commentary - - Using Chinese to summary - -3. Format your response as JSON with this structure: -{{ - "agent": "{agent_name}", - "task_id": "{task_id}", - "summaries": [ - {{ - "story_id": , - "title": "", - "url": "<url>", - "summary": "<your summary>", - "key_points": ["point1", "point2", ...], - "analysis": "<your analysis>" - }}, - ... - ] -}} - -4. Save your complete JSON results to !!!!agfs!!!! not local file system (use agfs tool to upload): {results_path}/{task_id}/agent-{agent_name}.json - -Use the WebFetch tool to retrieve article content. Focus on extracting meaningful insights. -""" - - # Enqueue task - queue_path = f"{queue_prefix}/{agent_name}" - success = enqueue_task(queue_path, task_prompt, agfs_api_url) - - if success: - assignment[agent_name] = len(agent_stories) - print(f"✅ {agent_name}: {len(agent_stories)} stories assigned") - else: - assignment[agent_name] = 0 - print(f"❌ {agent_name}: Failed to assign stories") - - print(f"\n{'=' * 80}") - print(f"✅ Distribution complete") - print(f"{'=' * 80}\n") - - return assignment - - -def enqueue_task( - queue_path: str, task_data: str, agfs_api_url: Optional[str] = None -) -> bool: - """Enqueue a task to a specific queue""" - enqueue_path = f"{queue_path}/enqueue" - - try: - # Initialize AGFS client - api_url = agfs_api_url or "http://localhost:8080" - client = AGFSClient(api_url) - - # Write task data to enqueue path - client.write(enqueue_path, task_data.encode("utf-8")) - return True - - except Exception as e: - print(f"Error enqueueing to {queue_path}: {e}", file=sys.stderr) - return False - - -def wait_for_results( - results_path: str, - expected_count: int, - timeout: int = 600, - poll_interval: int = 5, - agfs_api_url: Optional[str] = None, -) -> List[Dict[str, Any]]: - """Wait for all agents to complete and collect results""" - print(f"\n{'=' * 80}") - print(f"⏳ WAITING FOR {expected_count} AGENT RESULTS") - print(f"{'=' * 80}") - print(f"Results path: {results_path}") - print(f"Timeout: {timeout}s") - print(f"{'=' * 80}\n") - - start_time = time.time() - collected_results = [] - seen_files = set() - - while len(collected_results) < expected_count: - elapsed = time.time() - start_time - if elapsed > timeout: - print(f"\n⏱️ Timeout reached after {elapsed:.0f}s") - print(f"Collected {len(collected_results)}/{expected_count} results") - break - - # List current results - result_files = list_files(results_path, agfs_api_url) - - # Process new files - for file_name in result_files: - if file_name not in seen_files and file_name.endswith(".json"): - content = read_file(f"{results_path}/{file_name}", agfs_api_url) - if content: - try: - result_data = json.loads(content) - collected_results.append( - { - "file_name": file_name, - "data": result_data, - "timestamp": datetime.now().isoformat(), - } - ) - seen_files.add(file_name) - print( - f"📥 Result {len(collected_results)}/{expected_count}: {file_name}" - ) - except json.JSONDecodeError: - print(f"⚠️ Failed to parse JSON from {file_name}") - - if len(collected_results) >= expected_count: - break - - remaining = expected_count - len(collected_results) - print( - f"[{datetime.now().strftime('%H:%M:%S')}] " - f"Waiting for {remaining} more result(s)... (elapsed: {elapsed:.0f}s)" - ) - time.sleep(poll_interval) - - print(f"\n{'=' * 80}") - print(f"✅ COLLECTION COMPLETE: {len(collected_results)}/{expected_count} results") - print(f"{'=' * 80}\n") - - return collected_results - - -def list_files(path: str, agfs_api_url: Optional[str] = None) -> List[str]: - """List files in a AGFS directory""" - try: - # Initialize AGFS client - api_url = agfs_api_url or "http://localhost:8080" - client = AGFSClient(api_url) - - # List directory and extract file names - files = client.ls(path) - return [f["name"] for f in files if not f.get("isDir", False)] - except Exception: - pass - return [] - - -def read_file(file_path: str, agfs_api_url: Optional[str] = None) -> Optional[str]: - """Read a file from AGFS""" - try: - # Initialize AGFS client - api_url = agfs_api_url or "http://localhost:8080" - client = AGFSClient(api_url) - - # Read file content - content = client.cat(file_path) - return content.decode("utf-8") - except Exception: - pass - return None - - -def compile_final_report( - results: List[Dict[str, Any]], stories: List[Dict[str, Any]], task_id: str -) -> str: - """Compile all agent results into a final comprehensive report""" - print(f"\n{'=' * 80}") - print(f"📝 COMPILING FINAL REPORT") - print(f"{'=' * 80}\n") - - report = f"""# HackerNews Top Stories Research Report -Task ID: {task_id} -Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - -## Overview -This report summarizes the top {len(stories)} stories from HackerNews, analyzed by {len(results)} AI agents working in parallel. - ---- - -## Story Summaries - -""" - - # Organize summaries by story - story_summaries = {} - for result in results: - agent_name = result["data"].get("agent", "unknown") - summaries = result["data"].get("summaries", []) - - for summary in summaries: - story_id = summary.get("story_id") - if story_id not in story_summaries: - story_summaries[story_id] = [] - story_summaries[story_id].append({"agent": agent_name, "summary": summary}) - - # Build report for each story - for i, story in enumerate(stories, 1): - story_id = story["id"] - report += f"\n### {i}. {story['title']}\n\n" - report += f"**URL:** {story['url']}\n\n" - report += f"**Stats:** {story['score']} points | " - report += f"by {story['by']} | " - report += f"{story['descendants']} comments\n\n" - - if story_id in story_summaries: - for agent_summary in story_summaries[story_id]: - agent = agent_summary["agent"] - summary_data = agent_summary["summary"] - - report += f"#### Analysis by {agent}\n\n" - report += f"**Summary:** {summary_data.get('summary', 'N/A')}\n\n" - - if summary_data.get("key_points"): - report += f"**Key Points:**\n" - for point in summary_data["key_points"]: - report += f"- {point}\n" - report += "\n" - - if summary_data.get("analysis"): - report += f"**Analysis:** {summary_data['analysis']}\n\n" - - report += "---\n\n" - else: - report += "*No analysis available for this story.*\n\n---\n\n" - - report += f""" -## Summary - -- Total stories analyzed: {len(stories)} -- Agents involved: {len(results)} -- Task ID: {task_id} -- Completion time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} - ---- - -*Generated by AGFS Parallel Research System* -""" - - print(f"✅ Report compiled successfully") - print(f"{'=' * 80}\n") - - return report - - -def save_report( - report: str, report_path: str, agfs_api_url: Optional[str] = None -) -> bool: - """Save the final report to AGFS""" - print(f"💾 Saving report to: {report_path}") - - try: - # Initialize AGFS client - api_url = agfs_api_url or "http://localhost:8080" - client = AGFSClient(api_url) - - # Write report content - client.write(report_path, report.encode("utf-8")) - print(f"✅ Report saved successfully\n") - return True - - except Exception as e: - print(f"❌ Error saving report: {e}\n") - return False - - -def main(): - """Main function""" - parser = argparse.ArgumentParser( - description="Fetch and analyze top HackerNews stories using parallel agents" - ) - - parser.add_argument( - "--count", - type=int, - default=10, - help="Number of top stories to fetch (default: 10)", - ) - parser.add_argument( - "--agents", - type=str, - default="agent1,agent2,agent3,agent4,agent5,agent6,agent7,agent8,agent9,agent10", - help="Comma-separated list of agent names (default: agent1,agent2,agent3,agent4,agent5,agent6,agent7,agent8,agent9,agent10)", - ) - parser.add_argument( - "--queue-prefix", - type=str, - default="/queuefs", - help="Queue path prefix (default: /queuefs)", - ) - parser.add_argument( - "--results-path", - type=str, - default="/s3fs/aws/hackernews-results", - help="S3FS path for storing results (default: /s3fs/aws/hackernews-results)", - ) - parser.add_argument( - "--timeout", - type=int, - default=900, - help="Timeout for waiting results in seconds (default: 900)", - ) - parser.add_argument( - "--api-url", type=str, default=None, help="AGFS API server URL (optional)" - ) - - args = parser.parse_args() - - # Generate task ID - task_id = str(uuid.uuid4())[:8] - - print("\n" + "=" * 80) - print("🔬 HACKERNEWS PARALLEL RESEARCH") - print("=" * 80) - print(f"Task ID: {task_id}") - print(f"Stories: {args.count}") - print(f"Agents: {args.agents}") - print(f"Results path: {args.results_path}/{task_id}") - print("=" * 80) - - # Step 1: Fetch HackerNews stories - stories = fetch_hackernews_top_stories(args.count) - - if not stories: - print("❌ No stories fetched. Exiting.") - sys.exit(1) - - # Step 2: Distribute to agents - agent_names = [name.strip() for name in args.agents.split(",")] - task_results_path = f"{args.results_path}/{task_id}" - - assignment = distribute_stories_to_agents( - stories=stories, - agent_names=agent_names, - task_id=task_id, - results_path=args.results_path, - queue_prefix=args.queue_prefix, - agfs_api_url=args.api_url, - ) - - successful_agents = sum(1 for count in assignment.values() if count > 0) - - if successful_agents == 0: - print("❌ Failed to assign tasks to any agents. Exiting.") - sys.exit(1) - - # Step 3: Wait for results - results = wait_for_results( - results_path=task_results_path, - expected_count=successful_agents, - timeout=args.timeout, - poll_interval=10, - agfs_api_url=args.api_url, - ) - - # Step 4: Compile final report - if results: - final_report = compile_final_report(results, stories, task_id) - - # Print report to console - print("\n" + "=" * 80) - print("📄 FINAL REPORT") - print("=" * 80 + "\n") - print(final_report) - - # Save report to AGFS - report_path = f"{task_results_path}/FINAL_REPORT.md" - save_report(final_report, report_path, args.api_url) - - print("=" * 80) - print(f"✅ Research complete!") - print(f"📁 Report saved to: {report_path}") - print("=" * 80 + "\n") - else: - print("\n⚠️ No results collected. Cannot compile report.") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-mcp/demos/parallel_research.py b/third_party/agfs/agfs-mcp/demos/parallel_research.py deleted file mode 100755 index 68624b67e..000000000 --- a/third_party/agfs/agfs-mcp/demos/parallel_research.py +++ /dev/null @@ -1,374 +0,0 @@ -#!/usr/bin/env python3 -""" -Parallel Research - Broadcast research tasks to multiple agent queues -and collect results from S3FS -""" - -import argparse -import sys -import time -import uuid -from datetime import datetime -from typing import Any, Dict, List, Optional -from pyagfs import AGFSClient - - -class TaskBroadcaster: - """AGFS QueueFS task broadcaster for multiple agent queues""" - - def __init__( - self, - agent_queues: List[str], - agfs_api_baseurl: Optional[str] = "http://localhost:8080", - ): - """ - Initialize task broadcaster - - Args: - agent_queues: List of agent queue paths (e.g., ["/queuefs/agent1", "/queuefs/agent2"]) - agfs_api_baseurl: AGFS API server URL (optional) - """ - self.agent_queues = agent_queues - self.agfs_api_baseurl = agfs_api_baseurl - self.client = AGFSClient(agfs_api_baseurl) - - def enqueue_task(self, queue_path: str, task_data: str) -> bool: - """ - Enqueue a task to a specific queue - - Args: - queue_path: Queue path (e.g., "/queuefs/agent1") - task_data: Task data to enqueue - - Returns: - True if successful, False otherwise - """ - enqueue_path = f"{queue_path}/enqueue" - - try: - # Write task data to enqueue path using pyagfs client - self.client.write(enqueue_path, task_data.encode('utf-8')) - return True - - except Exception as e: - print(f"Error enqueueing to {queue_path}: {e}", file=sys.stderr) - return False - - def broadcast_task(self, task_data: str) -> Dict[str, bool]: - """ - Broadcast a task to all agent queues - - Args: - task_data: Task data to broadcast - - Returns: - Dictionary mapping queue paths to success status - """ - results = {} - - print(f"\n{'='*80}") - print(f"📡 BROADCASTING TASK TO {len(self.agent_queues)} AGENTS") - print(f"{'='*80}") - print(f"Task: {task_data}") - print(f"{'='*80}\n") - - for queue_path in self.agent_queues: - print(f"📤 Sending to {queue_path}...", end=" ") - success = self.enqueue_task(queue_path, task_data) - results[queue_path] = success - - if success: - print("✅ Success") - else: - print("❌ Failed") - - print() - return results - - -class ResultsCollector: - """Collect and monitor results from S3FS""" - - def __init__( - self, - results_path: str, - agfs_api_baseurl: Optional[str] = "http://localhost:8080", - ): - """ - Initialize results collector - - Args: - results_path: S3FS path where results are stored - agfs_api_baseurl: AGFS API server URL (optional) - """ - self.results_path = results_path - self.agfs_api_baseurl = agfs_api_baseurl - self.client = AGFSClient(agfs_api_baseurl) - - def list_results(self) -> List[str]: - """ - List all result files in the results directory - - Returns: - List of result file paths - """ - try: - # List directory and extract file names - files = self.client.ls(self.results_path) - return [f['name'] for f in files if not f.get('isDir', False)] - except Exception: - return [] - - def read_result(self, result_file: str) -> Optional[str]: - """ - Read a result file - - Args: - result_file: Result file name - - Returns: - File content, None if failed - """ - file_path = f"{self.results_path}/{result_file}" - try: - content = self.client.cat(file_path) - return content.decode('utf-8') - except Exception: - return None - - def wait_for_results( - self, - expected_count: int, - timeout: int = 600, - poll_interval: int = 5 - ) -> List[Dict[str, Any]]: - """ - Wait for all agents to complete and collect results - - Args: - expected_count: Number of results to wait for - timeout: Maximum wait time in seconds - poll_interval: How often to check for new results (in seconds) - - Returns: - List of result dictionaries - """ - print(f"\n{'='*80}") - print(f"⏳ WAITING FOR {expected_count} AGENT RESULTS") - print(f"{'='*80}") - print(f"Results path: {self.results_path}") - print(f"Timeout: {timeout}s") - print(f"{'='*80}\n") - - start_time = time.time() - collected_results = [] - seen_files = set() - - while len(collected_results) < expected_count: - # Check timeout - elapsed = time.time() - start_time - if elapsed > timeout: - print(f"\n⏱️ Timeout reached after {elapsed:.0f}s") - print(f"Collected {len(collected_results)}/{expected_count} results") - break - - # List current results - result_files = self.list_results() - - # Process new files - for file_name in result_files: - if file_name not in seen_files: - content = self.read_result(file_name) - if content: - collected_results.append({ - "file_name": file_name, - "content": content, - "timestamp": datetime.now().isoformat() - }) - seen_files.add(file_name) - - print(f"📥 Result {len(collected_results)}/{expected_count}: {file_name}") - - # Check if we have all results - if len(collected_results) >= expected_count: - break - - # Wait before next check - remaining = expected_count - len(collected_results) - print(f"[{datetime.now().strftime('%H:%M:%S')}] " - f"Waiting for {remaining} more result(s)... " - f"(elapsed: {elapsed:.0f}s)") - time.sleep(poll_interval) - - print(f"\n{'='*80}") - print(f"✅ COLLECTION COMPLETE: {len(collected_results)}/{expected_count} results") - print(f"{'='*80}\n") - - return collected_results - - -def main(): - """Main function: broadcast research tasks to multiple agents""" - - parser = argparse.ArgumentParser( - description="Broadcast research tasks to multiple agent queues and collect results" - ) - - # Task parameters - parser.add_argument( - "task", - type=str, - help="Research task description to broadcast" - ) - parser.add_argument( - "--task-id", - type=str, - default=None, - help="Task ID (auto-generated if not specified)" - ) - - # Agent queue parameters - parser.add_argument( - "--agents", - type=str, - default="agent1,agent2,agent3", - help="Comma-separated list of agent names (default: agent1,agent2,agent3)" - ) - parser.add_argument( - "--queue-prefix", - type=str, - default="/queuefs", - help="Queue path prefix (default: /queuefs)" - ) - - # Results parameters - parser.add_argument( - "--results-path", - type=str, - default="/s3fs/aws/results", - help="S3FS path for storing results (default: /s3fs/aws/results)" - ) - parser.add_argument( - "--wait", - action="store_true", - help="Wait for all agents to complete and collect results" - ) - parser.add_argument( - "--timeout", - type=int, - default=600, - help="Timeout for waiting results in seconds (default: 600)" - ) - parser.add_argument( - "--poll-interval", - type=int, - default=5, - help="Interval for checking results in seconds (default: 5)" - ) - - # AGFS API parameters - parser.add_argument( - "--api-url", - type=str, - default=None, - help="AGFS API server URL (optional)" - ) - - args = parser.parse_args() - - # Generate task ID if not provided - task_id = args.task_id or str(uuid.uuid4()) - - # Parse agent names and create queue paths - agent_names = [name.strip() for name in args.agents.split(",")] - agent_queues = [f"{args.queue_prefix}/{name}" for name in agent_names] - - # Create task broadcaster - broadcaster = TaskBroadcaster( - agent_queues=agent_queues, - agfs_api_baseurl=args.api_url - ) - - # Create results path for this task - task_results_path = f"{args.results_path}/{task_id}" - - # Build the task prompt - task_prompt = f"""Research Task ID: {task_id} - -Research Topic: {args.task} - -Instructions: -1. Research the topic thoroughly from your assigned perspective -2. Provide detailed findings, insights, and recommendations -3. Save your complete results to: {task_results_path}/agent-${{YOUR_AGENT_NAME}}.txt - -Make sure to include: -- Your research methodology -- Key findings and insights -- References or sources (if applicable) -- Your conclusions and recommendations -""" - - print("\n" + "="*80) - print("🔬 PARALLEL RESEARCH TASK BROADCASTER") - print("="*80) - print(f"Task ID: {task_id}") - print(f"Research: {args.task}") - print(f"Agents: {', '.join(agent_names)} ({len(agent_names)} total)") - print(f"Results path: {task_results_path}") - print(f"Wait mode: {'Enabled' if args.wait else 'Disabled'}") - print("="*80) - - # Broadcast task to all agents - results = broadcaster.broadcast_task(task_prompt) - - # Count successful broadcasts - success_count = sum(1 for success in results.values() if success) - - print(f"\n{'='*80}") - print(f"📊 BROADCAST SUMMARY") - print(f"{'='*80}") - print(f"Total agents: {len(agent_queues)}") - print(f"Successful: {success_count}") - print(f"Failed: {len(agent_queues) - success_count}") - print(f"{'='*80}\n") - - if success_count == 0: - print("❌ No tasks were successfully broadcasted!") - sys.exit(1) - - # Wait for results if requested - if args.wait: - collector = ResultsCollector( - results_path=task_results_path, - agfs_api_baseurl=args.api_url - ) - - collected_results = collector.wait_for_results( - expected_count=success_count, - timeout=args.timeout, - poll_interval=args.poll_interval - ) - - # Display collected results - if collected_results: - print(f"\n{'='*80}") - print(f"📋 COLLECTED RESULTS") - print(f"{'='*80}\n") - - for i, result in enumerate(collected_results, 1): - print(f"\n--- Result {i}: {result['file_name']} ---") - print(f"Timestamp: {result['timestamp']}") - print(f"\nContent:\n{result['content']}") - print("-" * 80) - else: - print("\n⚠️ No results were collected within the timeout period") - else: - print("💡 Tip: Use --wait to automatically collect results when agents complete") - print(f"💡 Results will be saved to: {task_results_path}/") - - print("\n✅ Done!") - - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-mcp/demos/start_agents.sh b/third_party/agfs/agfs-mcp/demos/start_agents.sh deleted file mode 100755 index 35a7f7f28..000000000 --- a/third_party/agfs/agfs-mcp/demos/start_agents.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/bin/bash -# Start multiple task_loop agents in the background - -set -e - -# Configuration -AGENTS=${AGENTS:-"agent1 agent2 agent3 agent4 agent5 agent6 agent7 agent8 agent9 agent10"} -QUEUE_PREFIX=${QUEUE_PREFIX:-"/queuefs"} -API_URL=${API_URL:-"http://localhost:8080"} -WORKING_DIR=${WORKING_DIR:-"."} -CLAUDE_TIMEOUT=${CLAUDE_TIMEOUT:-600} -ALLOWED_TOOLS=${ALLOWED_TOOLS:-"WebFetch,Read,Write,Bash,Glob,Grep,agfs"} - -# Colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}================================${NC}" -echo -e "${BLUE}🚀 Starting AGFS Task Loop Agents${NC}" -echo -e "${BLUE}================================${NC}" -echo "" - -# Create logs directory -LOGS_DIR="./logs" -mkdir -p "$LOGS_DIR" - -echo -e "${YELLOW}Configuration:${NC}" -echo -e " Agents: ${AGENTS}" -echo -e " Queue prefix: ${QUEUE_PREFIX}" -echo -e " API URL: ${API_URL}" -echo -e " Working dir: ${WORKING_DIR}" -echo -e " Logs dir: ${LOGS_DIR}" -echo -e " Timeout: ${CLAUDE_TIMEOUT}s" -echo -e " Allowed tools: ${ALLOWED_TOOLS}" -echo "" - -# Array to store PIDs -declare -a PIDS=() - -# Start each agent -for agent in $AGENTS; do - QUEUE_PATH="${QUEUE_PREFIX}/${agent}" - LOG_FILE="${LOGS_DIR}/${agent}.log" - PID_FILE="${LOGS_DIR}/${agent}.pid" - - echo -e "${GREEN}Starting ${agent}...${NC}" - echo -e " Queue: ${QUEUE_PATH}" - echo -e " Log file: ${LOG_FILE}" - - # Start task_loop in background - nohup uv run python -u task_loop.py \ - --queue-path "$QUEUE_PATH" \ - --api-url "$API_URL" \ - --claude-timeout "$CLAUDE_TIMEOUT" \ - --allowed-tools "$ALLOWED_TOOLS" \ - --working-dir "$WORKING_DIR" \ - --name "$agent" \ - > "$LOG_FILE" 2>&1 & - - # Save PID - AGENT_PID=$! - echo $AGENT_PID > "$PID_FILE" - PIDS+=($AGENT_PID) - - echo -e " ${GREEN}✓${NC} Started (PID: ${AGENT_PID})" - echo "" - - # Small delay between agent starts - sleep 1 -done - -echo -e "${BLUE}================================${NC}" -echo -e "${GREEN}✅ All agents started!${NC}" -echo -e "${BLUE}================================${NC}" -echo "" -echo -e "${YELLOW}Agent PIDs:${NC}" -for agent in $AGENTS; do - PID_FILE="${LOGS_DIR}/${agent}.pid" - if [ -f "$PID_FILE" ]; then - PID=$(cat "$PID_FILE") - echo -e " ${agent}: ${PID}" - fi -done -echo "" - -echo -e "${YELLOW}Useful commands:${NC}" -echo -e " View all logs: tail -f ${LOGS_DIR}/*.log" -echo -e " View agent1 log: tail -f ${LOGS_DIR}/agent1.log" -echo -e " Stop all agents: ./stop_agents.sh" -echo -e " Check status: ps aux | grep task_loop" -echo "" - -echo -e "${GREEN}Agents are now running in the background!${NC}" diff --git a/third_party/agfs/agfs-mcp/demos/start_agents_tmux.sh b/third_party/agfs/agfs-mcp/demos/start_agents_tmux.sh deleted file mode 100755 index bda4037a4..000000000 --- a/third_party/agfs/agfs-mcp/demos/start_agents_tmux.sh +++ /dev/null @@ -1,125 +0,0 @@ -#!/bin/bash -# Start multiple task_loop agents in tmux panes (10 panes in 1 window) - -set -e - -# Configuration -AGENTS=${AGENTS:-"agent1 agent2 agent3 agent4 agent5 agent6 agent7 agent8 agent9 agent10"} -QUEUE_PREFIX=${QUEUE_PREFIX:-"/queuefs"} -API_URL=${API_URL:-"http://localhost:8080"} -WORKING_DIR=${WORKING_DIR:-"."} -CLAUDE_TIMEOUT=${CLAUDE_TIMEOUT:-600} -ALLOWED_TOOLS=${ALLOWED_TOOLS:-"WebFetch,Read,Write,Bash,Glob,Grep,agfs"} -SESSION_NAME=${SESSION_NAME:-"agfs-agents"} - -# Colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}================================${NC}" -echo -e "${BLUE}🚀 Starting AGFS Task Loop Agents in Tmux${NC}" -echo -e "${BLUE}================================${NC}" -echo "" - -# Check if tmux is installed -if ! command -v tmux &> /dev/null; then - echo -e "${RED}Error: tmux is not installed${NC}" - echo "Please install tmux first:" - echo " macOS: brew install tmux" - echo " Ubuntu: sudo apt-get install tmux" - exit 1 -fi - -# Create logs directory -LOGS_DIR="./logs" -mkdir -p "$LOGS_DIR" - -echo -e "${YELLOW}Configuration:${NC}" -echo -e " Agents: ${AGENTS}" -echo -e " Queue prefix: ${QUEUE_PREFIX}" -echo -e " API URL: ${API_URL}" -echo -e " Working dir: ${WORKING_DIR}" -echo -e " Logs dir: ${LOGS_DIR}" -echo -e " Timeout: ${CLAUDE_TIMEOUT}s" -echo -e " Allowed tools: ${ALLOWED_TOOLS}" -echo -e " Session name: ${SESSION_NAME}" -echo "" - -# Check if already inside tmux -if [ -n "$TMUX" ]; then - echo -e "${RED}Error: You are already inside a tmux session${NC}" - echo -e "${YELLOW}Please exit tmux first or run from outside tmux:${NC}" - echo -e " ${GREEN}exit${NC} (or press Ctrl-b + d to detach)" - echo "" - echo -e "${YELLOW}Or if you want to force it, run:${NC}" - echo -e " ${GREEN}TMUX= ./start_agents.sh${NC}" - exit 1 -fi - -# Kill existing session if it exists -if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then - echo -e "${YELLOW}Killing existing session: ${SESSION_NAME}${NC}" - tmux kill-session -t "$SESSION_NAME" -fi - -# Convert agents to array -AGENTS_ARRAY=($AGENTS) -TOTAL_AGENTS=${#AGENTS_ARRAY[@]} - -echo -e "${GREEN}Creating tmux session with ${TOTAL_AGENTS} panes...${NC}" -echo "" - -# Create session with first pane and start first agent -FIRST_AGENT="${AGENTS_ARRAY[0]}" -FIRST_QUEUE_PATH="${QUEUE_PREFIX}/${FIRST_AGENT}" -FIRST_LOG_FILE="${LOGS_DIR}/${FIRST_AGENT}.log" - -echo -e "${GREEN}Creating pane 1 and starting ${FIRST_AGENT}${NC}" -tmux new-session -d -s "$SESSION_NAME" -n "agents" -tmux send-keys -t "$SESSION_NAME" "uv run python -u task_loop.py --queue-path \"$FIRST_QUEUE_PATH\" --api-url \"$API_URL\" --claude-timeout \"$CLAUDE_TIMEOUT\" --allowed-tools \"$ALLOWED_TOOLS\" --working-dir \"$WORKING_DIR\" --name \"$FIRST_AGENT\" 2>&1 | tee \"$FIRST_LOG_FILE\"" C-m - -# Create remaining panes and start agents immediately -for i in $(seq 1 $((TOTAL_AGENTS - 1))); do - agent="${AGENTS_ARRAY[$i]}" - QUEUE_PATH="${QUEUE_PREFIX}/${agent}" - LOG_FILE="${LOGS_DIR}/${agent}.log" - - echo -e "${GREEN}Creating pane $((i + 1)) and starting ${agent}${NC}" - tmux split-window -t "$SESSION_NAME" -h - tmux send-keys -t "$SESSION_NAME" "uv run python -u task_loop.py --queue-path \"$QUEUE_PATH\" --api-url \"$API_URL\" --claude-timeout \"$CLAUDE_TIMEOUT\" --allowed-tools \"$ALLOWED_TOOLS\" --working-dir \"$WORKING_DIR\" --name \"$agent\" 2>&1 | tee \"$LOG_FILE\"" C-m - tmux select-layout -t "$SESSION_NAME" tiled -done - -echo "" -echo -e "${BLUE}================================${NC}" -echo -e "${GREEN}✅ All ${TOTAL_AGENTS} agents started in tmux!${NC}" -echo -e "${BLUE}================================${NC}" -echo "" - -echo -e "${YELLOW}Tmux commands:${NC}" -echo -e " Attach to session: ${GREEN}tmux attach -t ${SESSION_NAME}${NC}" -echo -e " List panes: ${GREEN}tmux list-panes -t ${SESSION_NAME}${NC}" -echo -e " Kill session: ${GREEN}tmux kill-session -t ${SESSION_NAME}${NC}" -echo "" -echo -e "${YELLOW}Inside tmux:${NC}" -echo -e " Switch panes: ${GREEN}Ctrl-b + Arrow keys${NC}" -echo -e " Switch to pane: ${GREEN}Ctrl-b + q + <number>${NC}" -echo -e " Zoom pane: ${GREEN}Ctrl-b + z${NC} (toggle fullscreen)" -echo -e " Sync all panes: ${GREEN}Ctrl-b + Ctrl-Y${NC} (同时给所有agents发命令)" -echo -e " Detach: ${GREEN}Ctrl-b + d${NC}" -echo "" -echo -e "${YELLOW}Logs:${NC}" -echo -e " View all logs: tail -f ${LOGS_DIR}/*.log" -echo -e " View agent1 log: tail -f ${LOGS_DIR}/agent1.log" -echo "" - -echo -e "${GREEN}🎬 Now attaching to tmux session...${NC}" -echo -e "${YELLOW} Press Ctrl-b + d to detach${NC}" -echo "" -sleep 2 - -# Attach to the session -tmux attach -t "$SESSION_NAME" diff --git a/third_party/agfs/agfs-mcp/demos/stop_agents.sh b/third_party/agfs/agfs-mcp/demos/stop_agents.sh deleted file mode 100755 index 29d81f7c5..000000000 --- a/third_party/agfs/agfs-mcp/demos/stop_agents.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/bin/bash -# Stop all running task_loop agents - -set -e - -# Configuration -LOGS_DIR=${LOGS_DIR:-"./logs"} - -# Colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}================================${NC}" -echo -e "${BLUE}🛑 Stopping AGFS Task Loop Agents${NC}" -echo -e "${BLUE}================================${NC}" -echo "" - -if [ ! -d "$LOGS_DIR" ]; then - echo -e "${YELLOW}No logs directory found. No agents to stop.${NC}" - exit 0 -fi - -# Find all PID files -PID_FILES=$(find "$LOGS_DIR" -name "*.pid" 2>/dev/null) - -if [ -z "$PID_FILES" ]; then - echo -e "${YELLOW}No PID files found. No agents to stop.${NC}" - exit 0 -fi - -# Stop each agent -STOPPED=0 -FAILED=0 - -for PID_FILE in $PID_FILES; do - AGENT_NAME=$(basename "$PID_FILE" .pid) - - if [ -f "$PID_FILE" ]; then - PID=$(cat "$PID_FILE") - - echo -e "${YELLOW}Stopping ${AGENT_NAME} (PID: ${PID})...${NC}" - - # Check if process is running - if ps -p $PID > /dev/null 2>&1; then - # Try graceful shutdown first (SIGTERM) - kill $PID 2>/dev/null || true - sleep 1 - - # Check if still running, force kill if needed - if ps -p $PID > /dev/null 2>&1; then - echo -e " ${YELLOW}Forcing shutdown...${NC}" - kill -9 $PID 2>/dev/null || true - fi - - # Verify it's stopped - if ! ps -p $PID > /dev/null 2>&1; then - echo -e " ${GREEN}✓${NC} Stopped successfully" - ((STOPPED++)) - else - echo -e " ${RED}✗${NC} Failed to stop" - ((FAILED++)) - fi - else - echo -e " ${YELLOW}⚠${NC} Process not running" - fi - - # Remove PID file - rm -f "$PID_FILE" - fi -done - -echo "" -echo -e "${BLUE}================================${NC}" -if [ $FAILED -eq 0 ]; then - echo -e "${GREEN}✅ All agents stopped (${STOPPED} stopped)${NC}" -else - echo -e "${YELLOW}⚠️ Stopped ${STOPPED}, failed ${FAILED}${NC}" -fi -echo -e "${BLUE}================================${NC}" -echo "" diff --git a/third_party/agfs/agfs-mcp/demos/stop_agents_tmux.sh b/third_party/agfs/agfs-mcp/demos/stop_agents_tmux.sh deleted file mode 100755 index 9e8f31047..000000000 --- a/third_party/agfs/agfs-mcp/demos/stop_agents_tmux.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# Stop task_loop agents running in tmux session - -set -e - -# Configuration -SESSION_NAME=${SESSION_NAME:-"agfs-agents"} - -# Colors -GREEN='\033[0;32m' -BLUE='\033[0;34m' -YELLOW='\033[1;33m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -echo -e "${BLUE}================================${NC}" -echo -e "${BLUE}🛑 Stopping AGFS Task Loop Agents${NC}" -echo -e "${BLUE}================================${NC}" -echo "" - -# Check if tmux is installed -if ! command -v tmux &> /dev/null; then - echo -e "${RED}Error: tmux is not installed${NC}" - exit 1 -fi - -# Check if session exists -if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then - echo -e "${YELLOW}Found tmux session: ${SESSION_NAME}${NC}" - - # List panes before killing - echo -e "${BLUE}Active panes:${NC}" - tmux list-panes -t "$SESSION_NAME" -F " Pane #{pane_index}: #{pane_current_command}" 2>/dev/null || true - echo "" - - # Kill the session - echo -e "${YELLOW}Killing tmux session: ${SESSION_NAME}${NC}" - tmux kill-session -t "$SESSION_NAME" - - echo -e "${GREEN}✅ Tmux session stopped${NC}" -else - echo -e "${YELLOW}No tmux session found with name: ${SESSION_NAME}${NC}" -fi - -# Check for any stray task_loop.py processes -echo "" -echo -e "${BLUE}Checking for stray task_loop.py processes...${NC}" - -# Find task_loop.py processes (excluding grep itself) -STRAY_PIDS=$(ps aux | grep '[t]ask_loop.py' | awk '{print $2}' || true) - -if [ -n "$STRAY_PIDS" ]; then - echo -e "${YELLOW}Found stray task_loop.py processes:${NC}" - ps aux | grep '[t]ask_loop.py' | awk '{print " PID: " $2 " - " $11 " " $12 " " $13}' - echo "" - echo -e "${YELLOW}Killing stray processes...${NC}" - echo "$STRAY_PIDS" | xargs kill 2>/dev/null || true - sleep 1 - - # Check if any are still running - REMAINING=$(ps aux | grep '[t]ask_loop.py' | awk '{print $2}' || true) - if [ -n "$REMAINING" ]; then - echo -e "${RED}Some processes didn't stop, using kill -9...${NC}" - echo "$REMAINING" | xargs kill -9 2>/dev/null || true - fi - - echo -e "${GREEN}✅ Stray processes killed${NC}" -else - echo -e "${GREEN}No stray processes found${NC}" -fi - -echo "" -echo -e "${BLUE}================================${NC}" -echo -e "${GREEN}✅ All agents stopped${NC}" -echo -e "${BLUE}================================${NC}" -echo "" diff --git a/third_party/agfs/agfs-mcp/demos/task_loop.py b/third_party/agfs/agfs-mcp/demos/task_loop.py deleted file mode 100755 index 8515ed6e8..000000000 --- a/third_party/agfs/agfs-mcp/demos/task_loop.py +++ /dev/null @@ -1,415 +0,0 @@ -#!/usr/bin/env python3 -""" -Task Loop - Fetch tasks from AGFS QueueFS and execute with Claude Code -""" - -import argparse -import json -import subprocess -import sys -import time -from datetime import datetime -from typing import Any, Dict, Optional -from pyagfs import AGFSClient - - -class TaskQueue: - """AGFS QueueFS task queue client""" - - def __init__( - self, - queue_path, - agfs_api_baseurl: Optional[str] = "http://localhost:8080", - ): - """ - Initialize task queue client - - Args: - queue_path: QueueFS mount path - agfs_api_baseurl: AGFS API server URL (optional) - """ - self.queue_path = queue_path - self.agfs_api_baseurl = agfs_api_baseurl - self.dequeue_path = f"{queue_path}/dequeue" - self.size_path = f"{queue_path}/size" - self.peek_path = f"{queue_path}/peek" - self.client = AGFSClient(agfs_api_baseurl) - - def ensure_queue_exists(self) -> bool: - """ - Ensure queue directory exists, create if not - - Returns: - True if queue exists or was created successfully, False otherwise - """ - try: - # Try to create the queue directory - # QueueFS requires explicit mkdir to create queues - self.client.mkdir(self.queue_path) - print(f"Successfully created queue: {self.queue_path}", file=sys.stderr) - return True - except Exception as e: - # If mkdir fails, check if it's because queue already exists - error_msg = str(e).lower() - if "exists" in error_msg or "already" in error_msg: - # Queue already exists, this is fine - return True - else: - # Other error occurred - print(f"Failed to create queue: {self.queue_path}: {e}", file=sys.stderr) - return False - - def get_queue_size(self) -> Optional[int]: - """ - Get queue size - - Returns: - Number of messages in queue, None if failed - """ - try: - content = self.client.cat(self.size_path) - output = content.decode('utf-8').strip() - return int(output) - except ValueError: - print(f"Warning: Cannot parse queue size: {output}", file=sys.stderr) - return None - except Exception: - return None - - def peek_task(self) -> Optional[Dict[str, Any]]: - """ - Peek at next task without removing it - - Returns: - Task data dictionary, None if failed - """ - try: - content = self.client.cat(self.peek_path) - output = content.decode('utf-8') - return json.loads(output) - except json.JSONDecodeError: - print(f"Warning: Cannot parse JSON: {output}", file=sys.stderr) - return None - except Exception: - return None - - def dequeue_task(self) -> Optional[Dict[str, Any]]: - """ - Get a task from queue (removes it) - - Returns: - Task data dictionary with format: {"id": "...", "data": "...", "timestamp": "..."} - Returns None if queue is empty or operation failed - """ - try: - content = self.client.cat(self.dequeue_path) - output = content.decode('utf-8') - return json.loads(output) - except json.JSONDecodeError: - print(f"Warning: Cannot parse JSON: {output}", file=sys.stderr) - return None - except Exception: - return None - - -class ClaudeCodeExecutor: - """Execute tasks using Claude Code in headless mode""" - - def __init__( - self, - timeout: int = 600, - allowed_tools: Optional[list[str]] = None, - name: str = "", - ): - """ - Initialize Claude Code executor - - Args: - timeout: Maximum execution time in seconds (default: 600) - allowed_tools: List of allowed tools (None = all tools allowed) - """ - self.timeout = timeout - self.allowed_tools = allowed_tools - self.agent_name = name - - def execute_task( - self, task_prompt: str, working_dir: Optional[str] = None - ) -> Dict[str, Any]: - """ - Execute a task using Claude Code in headless mode - - Args: - task_prompt: The task prompt to send to Claude Code - working_dir: Working directory for Claude Code (optional) - - Returns: - Dictionary with execution results including: - - success: bool - - result: str (Claude's response) - - error: str (error message if failed) - - duration_ms: int - - total_cost_usd: float - - session_id: str - """ - cmd = [ - "claude", - "-p", - task_prompt, - "--output-format", - "json", - "--permission-mode=bypassPermissions", - ] - - # Add allowed tools if specified - if self.allowed_tools: - cmd.extend(["--allowedTools", ",".join(self.allowed_tools)]) - - try: - print(f"\n[Executing Claude Code with streaming output...]") - print("-" * 80) - start_time = time.time() - - # Use Popen to enable streaming output - process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=working_dir, - ) - - # Stream stderr to console in real-time (Claude Code outputs logs to stderr) - stdout_lines = [] - stderr_lines = [] - - try: - # Read stderr line by line and print to console - while True: - stderr_line = process.stderr.readline() - if stderr_line: - print(stderr_line.rstrip(), file=sys.stderr) - stderr_lines.append(stderr_line) - - # Check if process has finished - if process.poll() is not None: - # Read any remaining output - remaining_stderr = process.stderr.read() - if remaining_stderr: - print(remaining_stderr.rstrip(), file=sys.stderr) - stderr_lines.append(remaining_stderr) - break - - # Read all stdout (JSON output) - stdout_data = process.stdout.read() - stdout_lines.append(stdout_data) - - except KeyboardInterrupt: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - raise - - execution_time = (time.time() - start_time) * 1000 # Convert to ms - print("-" * 80) - - stdout_output = ''.join(stdout_lines) - stderr_output = ''.join(stderr_lines) - - if process.returncode == 0: - try: - output = json.loads(stdout_output) - return { - "success": True, - "result": output.get("result", ""), - "error": None, - "duration_ms": output.get("duration_ms", execution_time), - "total_cost_usd": output.get("total_cost_usd", 0.0), - "session_id": output.get("session_id", ""), - } - except json.JSONDecodeError as e: - return { - "success": False, - "result": stdout_output, - "error": f"Failed to parse JSON output: {e}", - "duration_ms": execution_time, - "total_cost_usd": 0.0, - "session_id": "", - } - else: - return { - "success": False, - "result": "", - "error": f"Claude Code exited with code {process.returncode}: {stderr_output}", - "duration_ms": execution_time, - "total_cost_usd": 0.0, - "session_id": "", - } - - except FileNotFoundError: - return { - "success": False, - "result": "", - "error": "'claude' command not found. Please ensure Claude Code is installed.", - "duration_ms": 0, - "total_cost_usd": 0.0, - "session_id": "", - } - except Exception as e: - return { - "success": False, - "result": "", - "error": f"Unexpected error: {e}", - "duration_ms": 0, - "total_cost_usd": 0.0, - "session_id": "", - } - - -def main(): - """Main function: loop to fetch tasks and output to console""" - - # Parse command line arguments - parser = argparse.ArgumentParser( - description="Fetch tasks from AGFS QueueFS and execute with Claude Code" - ) - parser.add_argument( - "--queue-path", - type=str, - default="/queuefs/agent", - help="QueueFS mount path (default: /queuefs/agent)", - ) - parser.add_argument( - "--api-url", type=str, default="http://localhost:8080", help="AGFS API server URL (default: http://localhost:8080)" - ) - parser.add_argument( - "--poll-interval", - type=int, - default=2, - help="Poll interval in seconds when queue is empty (default: 2)", - ) - parser.add_argument( - "--claude-timeout", - type=int, - default=600, - help="Claude Code execution timeout in seconds (default: 600)", - ) - parser.add_argument( - "--allowed-tools", - type=str, - default=None, - help="Comma-separated list of allowed tools for Claude Code (default: all tools)", - ) - parser.add_argument( - "--working-dir", - type=str, - default=None, - help="Working directory for Claude Code execution (default: current directory)", - ) - - parser.add_argument("--name", type=str, default=None, help="agent name") - - args = parser.parse_args() - - # Parse allowed tools if specified - allowed_tools = None - if args.allowed_tools: - allowed_tools = [tool.strip() for tool in args.allowed_tools.split(",")] - - # Create task queue client - queue = TaskQueue(queue_path=args.queue_path, agfs_api_baseurl=args.api_url) - - # Ensure queue exists before starting - if not queue.ensure_queue_exists(): - print(f"Error: Failed to ensure queue exists at {queue.queue_path}", file=sys.stderr) - sys.exit(1) - - # Create Claude Code executor - executor = ClaudeCodeExecutor( - timeout=args.claude_timeout, allowed_tools=allowed_tools - ) - - print("=== AGFS Task Loop with Claude Code ===") - print(f"Monitoring queue: {queue.queue_path}") - if args.api_url: - print(f"AGFS API URL: {args.api_url}") - print(f"Poll interval: {args.poll_interval}s") - print(f"Claude timeout: {args.claude_timeout}s") - if allowed_tools: - print(f"Allowed tools: {', '.join(allowed_tools)}") - if args.working_dir: - print(f"Working directory: {args.working_dir}") - print("Press Ctrl+C to exit\n") - - try: - while True: - # Check queue size - size = queue.get_queue_size() - if size is not None and size > 0: - print(f"[Queue size: {size}]") - - # Fetch task - task = queue.dequeue_task() - - if task: - task_id = task.get("id", "N/A") - task_data = task.get("data", "") - task_timestamp = task.get("timestamp", "N/A") - - print("\n" + "=" * 80) - print(f"📥 NEW TASK RECEIVED") - print("=" * 80) - print(f"Task ID: {task_id}") - print(f"Timestamp: {task_timestamp}") - print(f"Prompt: {task_data}") - print("=" * 80) - - # Build complete prompt with task information and result upload instruction - full_prompt = f"""Task ID: {task_id} - Task: {task_data} - Your name is: {args.name}""" - - # Execute task with Claude Code - result = executor.execute_task( - task_prompt=full_prompt, working_dir=args.working_dir - ) - - # Display results - print("\n" + "=" * 80) - print(f"📤 TASK EXECUTION RESULT") - print("=" * 80) - print(f"Task ID: {task_id}") - print( - f"Status: {'✅ SUCCESS' if result['success'] else '❌ FAILED'}" - ) - print(f"Duration: {result['duration_ms']:.0f}ms") - if result["total_cost_usd"] > 0: - print(f"Cost: ${result['total_cost_usd']:.4f}") - if result["session_id"]: - print(f"Session ID: {result['session_id']}") - print("-" * 80) - - if result["success"]: - print("Result:") - print(result["result"]) - else: - print(f"Error: {result['error']}") - - print("=" * 80) - print() - - else: - # Queue is empty, wait before retrying - print( - f"[{datetime.now().strftime('%H:%M:%S')}] Queue is empty, waiting for new tasks..." - ) - time.sleep(args.poll_interval) - - except KeyboardInterrupt: - print("\n\n⏹️ Program stopped by user") - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-mcp/pyproject.toml b/third_party/agfs/agfs-mcp/pyproject.toml deleted file mode 100644 index 74d2a7582..000000000 --- a/third_party/agfs/agfs-mcp/pyproject.toml +++ /dev/null @@ -1,31 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "agfs-mcp" -version = "1.4.0" -description = "Model Context Protocol (MCP) server for AGFS (Plugable File System)" -readme = "README.md" -requires-python = ">=3.10" -authors = [ - { name = "agfs authors" } -] -dependencies = [ - "beautifulsoup4", - "requests", - "pyagfs>=1.4.0", - "mcp>=0.9.0", -] - -[tool.uv.sources] -pyagfs = { path = "../agfs-sdk/python", editable = true } - -[project.scripts] -agfs-mcp = "agfs_mcp.server:cli" - -[tool.uv] -dev-dependencies = [] - -[tool.hatch.build.targets.wheel] -packages = ["src/agfs_mcp"] diff --git a/third_party/agfs/agfs-mcp/src/agfs_mcp/__init__.py b/third_party/agfs/agfs-mcp/src/agfs_mcp/__init__.py deleted file mode 100644 index 0f6ef3efb..000000000 --- a/third_party/agfs/agfs-mcp/src/agfs_mcp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""AGFS MCP Server - Model Context Protocol server for AGFS""" - -__version__ = "1.0.0" diff --git a/third_party/agfs/agfs-mcp/src/agfs_mcp/server.py b/third_party/agfs/agfs-mcp/src/agfs_mcp/server.py deleted file mode 100644 index fabad9487..000000000 --- a/third_party/agfs/agfs-mcp/src/agfs_mcp/server.py +++ /dev/null @@ -1,732 +0,0 @@ -#!/usr/bin/env python3 -"""AGFS MCP Server - Expose AGFS operations through Model Context Protocol""" - -import json -import logging -from typing import Any, Optional, Dict -from mcp.server import Server -from mcp.types import Tool, TextContent, Prompt, PromptMessage -from pyagfs import AGFSClient, AGFSClientError, cp, upload, download - -# Configure logging -logging.basicConfig(level=logging.INFO) -logger = logging.getLogger("agfs-mcp") - - -class AGFSMCPServer: - """MCP Server for AGFS operations""" - - def __init__(self, agfs_url: str = "http://localhost:8080/api/v1"): - self.server = Server("agfs-mcp") - self.agfs_url = agfs_url - self.client: Optional[AGFSClient] = None - self._setup_handlers() - - def _get_client(self) -> AGFSClient: - """Get or create AGFS client""" - if self.client is None: - self.client = AGFSClient(self.agfs_url) - return self.client - - def _setup_handlers(self): - """Setup MCP request handlers""" - - @self.server.list_prompts() - async def list_prompts() -> list[Prompt]: - """List available prompts""" - return [ - Prompt( - name="agfs_introduction", - description="Introduction to AGFS (Agent File System) - core concepts and architecture" - ) - ] - - @self.server.get_prompt() - async def get_prompt(name: str, arguments: Optional[Dict[str, str]] = None) -> PromptMessage: - """Get prompt content""" - if name == "agfs_introduction": - return PromptMessage( - role="user", - content=TextContent( - type="text", - text="""# AGFS (Agent File System) - Introduction - -## Overview -AGFS Server is a RESTful file system server inspired by Plan9 that leverages a powerful plugin architecture. It exposes various services—including message queues, key-value stores, databases, and remote systems—through a unified virtual file system interface. - -## Core Philosophy -The system follows the Unix philosophy of "everything is a file" but extends it to modern cloud services and data stores. By representing diverse backend services as file hierarchies, AGFS provides a consistent, intuitive interface for accessing heterogeneous systems. - -## Key Features - -### Plugin Architecture -The system allows mounting multiple filesystems and services at different paths, enabling flexible service composition. Each plugin implements the filesystem interface but can represent any kind of backend service. - -### External Plugin Support -Plugins load dynamically from: -- Shared libraries (.so on Linux, .dylib on macOS, .dll on Windows) -- WebAssembly modules (.wasm) -- HTTP(S) URLs for remote plugin loading - -This enables extending AGFS without server recompilation or restart. - -### Unified API -A single HTTP REST interface handles operations across all mounted plugins: -- GET /api/v1/files?path=/xxx - Read file content -- PUT /api/v1/files?path=/xxx - Write file content -- GET /api/v1/directories?path=/xxx - List directory -- POST /api/v1/directories?path=/xxx - Create directory -- DELETE /api/v1/files?path=/xxx - Remove file/directory -- GET /api/v1/stat?path=/xxx - Get file info -- POST /api/v1/rename - Move/rename file -- POST /api/v1/grep - Search in files - -### Dynamic Management -Plugins can be managed at runtime via API: -- Mount/unmount plugins at any path -- Load/unload external plugins -- Configure multiple instances of the same plugin type -- Query mounted plugins and their configurations - -### Multi-Instance Capability -The same plugin type can run multiple independent instances. For example: -- Multiple database connections at /db/users, /db/products, /db/logs -- Multiple S3 buckets at /s3/backup, /s3/public, /s3/archive -- Multiple remote servers federated at /remote/server1, /remote/server2 - -## Architecture - -``` -┌─────────────────────────────────────────────┐ -│ HTTP REST API (Port 8080) │ -│ /api/v1/files, /directories │ -└───────────────────┬─────────────────────────┘ - │ - ┌──────────▼──────────┐ - │ MountableFS │ ← Central router - │ (Path → Plugin) │ - └──────────┬──────────┘ - │ - ┌───────────┴───────────┐ - │ │ - ┌────▼─────┐ ┌─────▼────┐ - │ Built-in │ │ External │ - │ Plugins │ │ Plugins │ - └────┬─────┘ └─────┬────┘ - │ │ - ┌────▼──────────────────────▼────┐ - │ QueueFS, KVFS, MemFS, SQLFS, │ - │ ProxyFS, S3FS, LocalFS, etc. │ - └───────────────────────────────┘ -``` - -The MountableFS layer routes requests to the appropriate plugin based on the requested path, enabling seamless integration of multiple services. - -## Built-in Plugins - -- **QueueFS**: Message queue operations via files (publish/subscribe) -- **KVFS**: Key-value data storage (simple get/set operations) -- **MemFS**: In-memory temporary storage (fast, volatile) -- **SQLFS**: Database-backed operations (persistent, queryable) -- **ProxyFS**: Remote server federation (mount remote AGFS servers) -- **S3FS**: S3-compatible object storage integration -- **LocalFS**: Local filesystem access -- **HTTPFS**: HTTP-based file access - -## Common Use Cases - -1. **Unified Data Access**: Access databases, object storage, and local files through a single interface -2. **Service Composition**: Combine multiple data sources at different mount points -3. **Remote Federation**: Mount remote AGFS servers as local directories -4. **Plugin Development**: Extend functionality with custom plugins (WebAssembly, shared libraries) -5. **Streaming Operations**: Stream large files or continuous data (logs, metrics) -6. **Pattern Matching**: Use grep for searching across different backends - -## Working with AGFS via MCP - -When using AGFS through this MCP server, you have access to all these capabilities through simple tool calls. Each tool operation maps to the AGFS REST API, allowing you to: -- Navigate mounted plugins as directory hierarchies -- Read/write data across different backend services -- Search for patterns using grep -- Manage plugin lifecycle (mount/unmount) -- Monitor system health - -The key insight is that whether you're reading from a SQL database at /db/users/data, an S3 bucket at /s3/logs/2024.txt, or a local file at /local/config.json, you use the same consistent file operations.""" - ) - ) - raise ValueError(f"Unknown prompt: {name}") - - @self.server.list_tools() - async def list_tools() -> list[Tool]: - """List available AGFS tools""" - return [ - Tool( - name="agfs_ls", - description="List directory contents in AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory path to list (default: /)", - "default": "/" - } - } - } - ), - Tool( - name="agfs_cat", - description="Read file content from AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path to read" - }, - "offset": { - "type": "integer", - "description": "Starting offset (default: 0)", - "default": 0 - }, - "size": { - "type": "integer", - "description": "Number of bytes to read (default: -1 for all)", - "default": -1 - } - }, - "required": ["path"] - } - ), - Tool( - name="agfs_write", - description="Write content to a file in AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File path to write to" - }, - "content": { - "type": "string", - "description": "Content to write to the file" - } - }, - "required": ["path", "content"] - } - ), - Tool( - name="agfs_mkdir", - description="Create a directory in AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Directory path to create" - }, - "mode": { - "type": "string", - "description": "Permissions mode (default: 755)", - "default": "755" - } - }, - "required": ["path"] - } - ), - Tool( - name="agfs_rm", - description="Remove a file or directory from AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to remove" - }, - "recursive": { - "type": "boolean", - "description": "Remove directories recursively (default: false)", - "default": False - } - }, - "required": ["path"] - } - ), - Tool( - name="agfs_stat", - description="Get file or directory information from AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to get information about" - } - }, - "required": ["path"] - } - ), - Tool( - name="agfs_mv", - description="Move or rename a file/directory in AGFS", - inputSchema={ - "type": "object", - "properties": { - "old_path": { - "type": "string", - "description": "Source path" - }, - "new_path": { - "type": "string", - "description": "Destination path" - } - }, - "required": ["old_path", "new_path"] - } - ), - Tool( - name="agfs_grep", - description="Search for pattern in files using regular expressions", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to search in (file or directory)" - }, - "pattern": { - "type": "string", - "description": "Regular expression pattern to search for" - }, - "recursive": { - "type": "boolean", - "description": "Search recursively in directories (default: false)", - "default": False - }, - "case_insensitive": { - "type": "boolean", - "description": "Case-insensitive search (default: false)", - "default": False - } - }, - "required": ["path", "pattern"] - } - ), - Tool( - name="agfs_mounts", - description="List all mounted plugins in AGFS", - inputSchema={ - "type": "object", - "properties": {} - } - ), - Tool( - name="agfs_mount", - description="Mount a plugin in AGFS", - inputSchema={ - "type": "object", - "properties": { - "fstype": { - "type": "string", - "description": "Filesystem type (e.g., 'sqlfs', 'memfs', 's3fs')" - }, - "path": { - "type": "string", - "description": "Mount path" - }, - "config": { - "type": "object", - "description": "Plugin configuration (varies by fstype)", - "default": {} - } - }, - "required": ["fstype", "path"] - } - ), - Tool( - name="agfs_unmount", - description="Unmount a plugin from AGFS", - inputSchema={ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Mount path to unmount" - } - }, - "required": ["path"] - } - ), - Tool( - name="agfs_health", - description="Check AGFS server health status", - inputSchema={ - "type": "object", - "properties": {} - } - ), - Tool( - name="agfs_cp", - description="Copy a file or directory within AGFS", - inputSchema={ - "type": "object", - "properties": { - "src": { - "type": "string", - "description": "Source path in AGFS" - }, - "dst": { - "type": "string", - "description": "Destination path in AGFS" - }, - "recursive": { - "type": "boolean", - "description": "Copy directories recursively (default: false)", - "default": False - }, - "stream": { - "type": "boolean", - "description": "Use streaming for large files (default: false)", - "default": False - } - }, - "required": ["src", "dst"] - } - ), - Tool( - name="agfs_upload", - description="Upload a file or directory from local filesystem to AGFS", - inputSchema={ - "type": "object", - "properties": { - "local_path": { - "type": "string", - "description": "Path to local file or directory" - }, - "remote_path": { - "type": "string", - "description": "Destination path in AGFS" - }, - "recursive": { - "type": "boolean", - "description": "Upload directories recursively (default: false)", - "default": False - }, - "stream": { - "type": "boolean", - "description": "Use streaming for large files (default: false)", - "default": False - } - }, - "required": ["local_path", "remote_path"] - } - ), - Tool( - name="agfs_download", - description="Download a file or directory from AGFS to local filesystem", - inputSchema={ - "type": "object", - "properties": { - "remote_path": { - "type": "string", - "description": "Path in AGFS" - }, - "local_path": { - "type": "string", - "description": "Destination path on local filesystem" - }, - "recursive": { - "type": "boolean", - "description": "Download directories recursively (default: false)", - "default": False - }, - "stream": { - "type": "boolean", - "description": "Use streaming for large files (default: false)", - "default": False - } - }, - "required": ["remote_path", "local_path"] - } - ), - Tool( - name="agfs_notify", - description="Send a notification message via QueueFS. Creates sender/receiver queues if they don't exist. Message is sent as JSON with from_name, message, and timestamp fields.", - inputSchema={ - "type": "object", - "properties": { - "queuefs_root": { - "type": "string", - "description": "Root path of QueueFS mount (default: /queuefs)", - "default": "/queuefs" - }, - "to": { - "type": "string", - "description": "Target queue name (receiver)" - }, - "from": { - "type": "string", - "description": "Source queue name (sender)" - }, - "data": { - "type": "string", - "description": "Message content to send (will be wrapped in JSON with from_name for callback)" - } - }, - "required": ["to", "from", "data"] - } - ), - ] - - @self.server.call_tool() - async def call_tool(name: str, arguments: Any) -> list[TextContent]: - """Handle tool calls""" - try: - client = self._get_client() - - if name == "agfs_ls": - path = arguments.get("path", "/") - result = client.ls(path) - return [TextContent( - type="text", - text=json.dumps(result, indent=2, ensure_ascii=False) - )] - - elif name == "agfs_cat": - path = arguments["path"] - offset = arguments.get("offset", 0) - size = arguments.get("size", -1) - content = client.cat(path, offset=offset, size=size) - # Try to decode as UTF-8, fallback to base64 for binary - try: - text = content.decode('utf-8') - except UnicodeDecodeError: - import base64 - text = f"[Binary content, base64 encoded]\n{base64.b64encode(content).decode('ascii')}" - return [TextContent(type="text", text=text)] - - elif name == "agfs_write": - path = arguments["path"] - content = arguments["content"] - result = client.write(path, content.encode('utf-8')) - return [TextContent(type="text", text=result)] - - elif name == "agfs_mkdir": - path = arguments["path"] - mode = arguments.get("mode", "755") - result = client.mkdir(path, mode=mode) - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_rm": - path = arguments["path"] - recursive = arguments.get("recursive", False) - result = client.rm(path, recursive=recursive) - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_stat": - path = arguments["path"] - result = client.stat(path) - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_mv": - old_path = arguments["old_path"] - new_path = arguments["new_path"] - result = client.mv(old_path, new_path) - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_grep": - path = arguments["path"] - pattern = arguments["pattern"] - recursive = arguments.get("recursive", False) - case_insensitive = arguments.get("case_insensitive", False) - result = client.grep( - path, - pattern, - recursive=recursive, - case_insensitive=case_insensitive - ) - return [TextContent( - type="text", - text=json.dumps(result, indent=2, ensure_ascii=False) - )] - - elif name == "agfs_mounts": - result = client.mounts() - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_mount": - fstype = arguments["fstype"] - path = arguments["path"] - config = arguments.get("config", {}) - result = client.mount(fstype, path, config) - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_unmount": - path = arguments["path"] - result = client.unmount(path) - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_health": - result = client.health() - return [TextContent( - type="text", - text=json.dumps(result, indent=2) - )] - - elif name == "agfs_cp": - src = arguments["src"] - dst = arguments["dst"] - recursive = arguments.get("recursive", False) - stream = arguments.get("stream", False) - cp(client, src, dst, recursive=recursive, stream=stream) - return [TextContent( - type="text", - text=f"Successfully copied {src} to {dst}" - )] - - elif name == "agfs_upload": - local_path = arguments["local_path"] - remote_path = arguments["remote_path"] - recursive = arguments.get("recursive", False) - stream = arguments.get("stream", False) - upload(client, local_path, remote_path, recursive=recursive, stream=stream) - return [TextContent( - type="text", - text=f"Successfully uploaded {local_path} to {remote_path}" - )] - - elif name == "agfs_download": - remote_path = arguments["remote_path"] - local_path = arguments["local_path"] - recursive = arguments.get("recursive", False) - stream = arguments.get("stream", False) - download(client, remote_path, local_path, recursive=recursive, stream=stream) - return [TextContent( - type="text", - text=f"Successfully downloaded {remote_path} to {local_path}" - )] - - elif name == "agfs_notify": - from datetime import datetime, timezone - - queuefs_root = arguments.get("queuefs_root", "/queuefs") - to = arguments["to"] - from_name = arguments["from"] - data = arguments["data"] - - # Ensure queuefs_root doesn't end with / - queuefs_root = queuefs_root.rstrip('/') - - # Create sender queue if it doesn't exist - from_queue_path = f"{queuefs_root}/{from_name}" - try: - client.stat(from_queue_path) - except AGFSClientError: - # Queue doesn't exist, create it - client.mkdir(from_queue_path) - logger.info(f"Created sender queue: {from_queue_path}") - - # Create receiver queue if it doesn't exist - to_queue_path = f"{queuefs_root}/{to}" - try: - client.stat(to_queue_path) - except AGFSClientError: - # Queue doesn't exist, create it - client.mkdir(to_queue_path) - logger.info(f"Created receiver queue: {to_queue_path}") - - # Wrap the message in JSON format with from_name for callback - message_json = { - "from": from_name, - "to": to, - "message": data, - "timestamp": datetime.now(timezone.utc).isoformat() - } - message_data = json.dumps(message_json, ensure_ascii=False) - - # Send the notification by writing to receiver's enqueue file - enqueue_path = f"{to_queue_path}/enqueue" - client.write(enqueue_path, message_data.encode('utf-8')) - - return [TextContent( - type="text", - text=f"Successfully sent notification from '{from_name}' to '{to}' queue" - )] - - else: - return [TextContent( - type="text", - text=f"Unknown tool: {name}" - )] - - except AGFSClientError as e: - logger.error(f"AGFS error in {name}: {e}") - return [TextContent( - type="text", - text=f"Error: {str(e)}" - )] - except Exception as e: - logger.error(f"Unexpected error in {name}: {e}", exc_info=True) - return [TextContent( - type="text", - text=f"Unexpected error: {str(e)}" - )] - - async def run(self): - """Run the MCP server""" - from mcp.server.stdio import stdio_server - - async with stdio_server() as (read_stream, write_stream): - await self.server.run( - read_stream, - write_stream, - self.server.create_initialization_options() - ) - - -async def main(): - """Main entry point""" - import os - import sys - - # Get AGFS server URL from environment or use default - agfs_url = os.getenv("AGFS_SERVER_URL", "http://localhost:8080") - - logger.info(f"Starting AGFS MCP Server (connecting to {agfs_url})") - - server = AGFSMCPServer(agfs_url) - await server.run() - - -def cli(): - """CLI entry point for package script""" - import asyncio - asyncio.run(main()) - - -if __name__ == "__main__": - import asyncio - asyncio.run(main()) diff --git a/third_party/agfs/agfs-mcp/uv.lock b/third_party/agfs/agfs-mcp/uv.lock deleted file mode 100644 index 217f69ff9..000000000 --- a/third_party/agfs/agfs-mcp/uv.lock +++ /dev/null @@ -1,912 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.10" - -[[package]] -name = "agfs-mcp" -version = "1.0.0" -source = { editable = "." } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "mcp" }, - { name = "pyagfs" }, - { name = "requests" }, -] - -[package.metadata] -requires-dist = [ - { name = "beautifulsoup4" }, - { name = "mcp", specifier = ">=0.9.0" }, - { name = "pyagfs", editable = "../agfs-sdk/python" }, - { name = "requests" }, -] - -[package.metadata.requires-dev] -dev = [] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097 }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, -] - -[[package]] -name = "beautifulsoup4" -version = "4.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "soupsieve" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392 }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283 }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504 }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811 }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402 }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217 }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079 }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475 }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829 }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211 }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036 }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184 }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790 }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344 }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560 }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613 }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476 }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374 }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597 }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574 }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971 }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972 }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078 }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076 }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820 }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635 }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, -] - -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "cryptography" -version = "46.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004 }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667 }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807 }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615 }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800 }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707 }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541 }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464 }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838 }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596 }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782 }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381 }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988 }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451 }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007 }, - { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012 }, - { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728 }, - { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078 }, - { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460 }, - { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237 }, - { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344 }, - { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564 }, - { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415 }, - { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457 }, - { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074 }, - { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569 }, - { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941 }, - { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339 }, - { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315 }, - { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331 }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248 }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089 }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029 }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222 }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280 }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958 }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714 }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970 }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236 }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642 }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126 }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573 }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695 }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720 }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740 }, - { url = "https://files.pythonhosted.org/packages/d9/cd/1a8633802d766a0fa46f382a77e096d7e209e0817892929655fe0586ae32/cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32", size = 3689163 }, - { url = "https://files.pythonhosted.org/packages/4c/59/6b26512964ace6480c3e54681a9859c974172fb141c38df11eadd8416947/cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c", size = 3429474 }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132 }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992 }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944 }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957 }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447 }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528 }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960 }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, -] - -[[package]] -name = "jsonschema" -version = "4.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "jsonschema-specifications" }, - { name = "referencing" }, - { name = "rpds-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040 }, -] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "referencing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, -] - -[[package]] -name = "mcp" -version = "1.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "jsonschema" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "pyjwt", extra = ["crypto"] }, - { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, - { name = "sse-starlette" }, - { name = "starlette" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/33/54/dd2330ef4611c27ae59124820863c34e1d3edb1133c58e6375e2d938c9c5/mcp-1.21.0.tar.gz", hash = "sha256:bab0a38e8f8c48080d787233343f8d301b0e1e95846ae7dead251b2421d99855", size = 452697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/47/850b6edc96c03bd44b00de9a0ca3c1cc71e0ba1cd5822955bc9e4eb3fad3/mcp-1.21.0-py3-none-any.whl", hash = "sha256:598619e53eb0b7a6513db38c426b28a4bdf57496fed04332100d2c56acade98b", size = 173672 }, -] - -[[package]] -name = "pyagfs" -version = "0.1.3" -source = { editable = "../agfs-sdk/python" } -dependencies = [ - { name = "requests" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.270" }, -] -provides-extras = ["dev"] - -[[package]] -name = "pycparser" -version = "2.23" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140 }, -] - -[[package]] -name = "pydantic" -version = "2.12.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400 }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, -] - -[[package]] -name = "pydantic-settings" -version = "2.12.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880 }, -] - -[[package]] -name = "pyjwt" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, -] - -[package.optional-dependencies] -crypto = [ - { name = "cryptography" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230 }, -] - -[[package]] -name = "python-multipart" -version = "0.0.20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546 }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432 }, - { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103 }, - { url = "https://files.pythonhosted.org/packages/57/38/d290720e6f138086fb3d5ffe0b6caa019a791dd57866940c82e4eeaf2012/pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b", size = 8778557 }, - { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031 }, - { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308 }, - { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930 }, - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543 }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040 }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102 }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700 }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700 }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318 }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714 }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800 }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540 }, -] - -[[package]] -name = "referencing" -version = "0.37.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "rpds-py" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, -] - -[[package]] -name = "rpds-py" -version = "0.28.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/f8/13bb772dc7cbf2c3c5b816febc34fa0cb2c64a08e0569869585684ce6631/rpds_py-0.28.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7b6013db815417eeb56b2d9d7324e64fcd4fa289caeee6e7a78b2e11fc9b438a", size = 362820 }, - { url = "https://files.pythonhosted.org/packages/84/91/6acce964aab32469c3dbe792cb041a752d64739c534e9c493c701ef0c032/rpds_py-0.28.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a4c6b05c685c0c03f80dabaeb73e74218c49deea965ca63f76a752807397207", size = 348499 }, - { url = "https://files.pythonhosted.org/packages/f1/93/c05bb1f4f5e0234db7c4917cb8dd5e2e0a9a7b26dc74b1b7bee3c9cfd477/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4794c6c3fbe8f9ac87699b131a1f26e7b4abcf6d828da46a3a52648c7930eba", size = 379356 }, - { url = "https://files.pythonhosted.org/packages/5c/37/e292da436f0773e319753c567263427cdf6c645d30b44f09463ff8216cda/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2e8456b6ee5527112ff2354dd9087b030e3429e43a74f480d4a5ca79d269fd85", size = 390151 }, - { url = "https://files.pythonhosted.org/packages/76/87/a4e3267131616e8faf10486dc00eaedf09bd61c87f01e5ef98e782ee06c9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:beb880a9ca0a117415f241f66d56025c02037f7c4efc6fe59b5b8454f1eaa50d", size = 524831 }, - { url = "https://files.pythonhosted.org/packages/e1/c8/4a4ca76f0befae9515da3fad11038f0fce44f6bb60b21fe9d9364dd51fb0/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6897bebb118c44b38c9cb62a178e09f1593c949391b9a1a6fe777ccab5934ee7", size = 404687 }, - { url = "https://files.pythonhosted.org/packages/6a/65/118afe854424456beafbbebc6b34dcf6d72eae3a08b4632bc4220f8240d9/rpds_py-0.28.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b553dd06e875249fd43efd727785efb57a53180e0fde321468222eabbeaafa", size = 382683 }, - { url = "https://files.pythonhosted.org/packages/f7/bc/0625064041fb3a0c77ecc8878c0e8341b0ae27ad0f00cf8f2b57337a1e63/rpds_py-0.28.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:f0b2044fdddeea5b05df832e50d2a06fe61023acb44d76978e1b060206a8a476", size = 398927 }, - { url = "https://files.pythonhosted.org/packages/5d/1a/fed7cf2f1ee8a5e4778f2054153f2cfcf517748875e2f5b21cf8907cd77d/rpds_py-0.28.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05cf1e74900e8da73fa08cc76c74a03345e5a3e37691d07cfe2092d7d8e27b04", size = 411590 }, - { url = "https://files.pythonhosted.org/packages/c1/64/a8e0f67fa374a6c472dbb0afdaf1ef744724f165abb6899f20e2f1563137/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:efd489fec7c311dae25e94fe7eeda4b3d06be71c68f2cf2e8ef990ffcd2cd7e8", size = 559843 }, - { url = "https://files.pythonhosted.org/packages/a9/ea/e10353f6d7c105be09b8135b72787a65919971ae0330ad97d87e4e199880/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ada7754a10faacd4f26067e62de52d6af93b6d9542f0df73c57b9771eb3ba9c4", size = 584188 }, - { url = "https://files.pythonhosted.org/packages/18/b0/a19743e0763caf0c89f6fc6ba6fbd9a353b24ffb4256a492420c5517da5a/rpds_py-0.28.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c2a34fd26588949e1e7977cfcbb17a9a42c948c100cab890c6d8d823f0586457", size = 550052 }, - { url = "https://files.pythonhosted.org/packages/de/bc/ec2c004f6c7d6ab1e25dae875cdb1aee087c3ebed5b73712ed3000e3851a/rpds_py-0.28.0-cp310-cp310-win32.whl", hash = "sha256:f9174471d6920cbc5e82a7822de8dfd4dcea86eb828b04fc8c6519a77b0ee51e", size = 215110 }, - { url = "https://files.pythonhosted.org/packages/6c/de/4ce8abf59674e17187023933547d2018363e8fc76ada4f1d4d22871ccb6e/rpds_py-0.28.0-cp310-cp310-win_amd64.whl", hash = "sha256:6e32dd207e2c4f8475257a3540ab8a93eff997abfa0a3fdb287cae0d6cd874b8", size = 223850 }, - { url = "https://files.pythonhosted.org/packages/a6/34/058d0db5471c6be7bef82487ad5021ff8d1d1d27794be8730aad938649cf/rpds_py-0.28.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:03065002fd2e287725d95fbc69688e0c6daf6c6314ba38bdbaa3895418e09296", size = 362344 }, - { url = "https://files.pythonhosted.org/packages/5d/67/9503f0ec8c055a0782880f300c50a2b8e5e72eb1f94dfc2053da527444dd/rpds_py-0.28.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28ea02215f262b6d078daec0b45344c89e161eab9526b0d898221d96fdda5f27", size = 348440 }, - { url = "https://files.pythonhosted.org/packages/68/2e/94223ee9b32332a41d75b6f94b37b4ce3e93878a556fc5f152cbd856a81f/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25dbade8fbf30bcc551cb352376c0ad64b067e4fc56f90e22ba70c3ce205988c", size = 379068 }, - { url = "https://files.pythonhosted.org/packages/b4/25/54fd48f9f680cfc44e6a7f39a5fadf1d4a4a1fd0848076af4a43e79f998c/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c03002f54cc855860bfdc3442928ffdca9081e73b5b382ed0b9e8efe6e5e205", size = 390518 }, - { url = "https://files.pythonhosted.org/packages/1b/85/ac258c9c27f2ccb1bd5d0697e53a82ebcf8088e3186d5d2bf8498ee7ed44/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9699fa7990368b22032baf2b2dce1f634388e4ffc03dfefaaac79f4695edc95", size = 525319 }, - { url = "https://files.pythonhosted.org/packages/40/cb/c6734774789566d46775f193964b76627cd5f42ecf246d257ce84d1912ed/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9b06fe1a75e05e0713f06ea0c89ecb6452210fd60e2f1b6ddc1067b990e08d9", size = 404896 }, - { url = "https://files.pythonhosted.org/packages/1f/53/14e37ce83202c632c89b0691185dca9532288ff9d390eacae3d2ff771bae/rpds_py-0.28.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9f83e7b326a3f9ec3ef84cda98fb0a74c7159f33e692032233046e7fd15da2", size = 382862 }, - { url = "https://files.pythonhosted.org/packages/6a/83/f3642483ca971a54d60caa4449f9d6d4dbb56a53e0072d0deff51b38af74/rpds_py-0.28.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:0d3259ea9ad8743a75a43eb7819324cdab393263c91be86e2d1901ee65c314e0", size = 398848 }, - { url = "https://files.pythonhosted.org/packages/44/09/2d9c8b2f88e399b4cfe86efdf2935feaf0394e4f14ab30c6c5945d60af7d/rpds_py-0.28.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9a7548b345f66f6695943b4ef6afe33ccd3f1b638bd9afd0f730dd255c249c9e", size = 412030 }, - { url = "https://files.pythonhosted.org/packages/dd/f5/e1cec473d4bde6df1fd3738be8e82d64dd0600868e76e92dfeaebbc2d18f/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9a40040aa388b037eb39416710fbcce9443498d2eaab0b9b45ae988b53f5c67", size = 559700 }, - { url = "https://files.pythonhosted.org/packages/8d/be/73bb241c1649edbf14e98e9e78899c2c5e52bbe47cb64811f44d2cc11808/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8f60c7ea34e78c199acd0d3cda37a99be2c861dd2b8cf67399784f70c9f8e57d", size = 584581 }, - { url = "https://files.pythonhosted.org/packages/9c/9c/ffc6e9218cd1eb5c2c7dbd276c87cd10e8c2232c456b554169eb363381df/rpds_py-0.28.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1571ae4292649100d743b26d5f9c63503bb1fedf538a8f29a98dce2d5ba6b4e6", size = 549981 }, - { url = "https://files.pythonhosted.org/packages/5f/50/da8b6d33803a94df0149345ee33e5d91ed4d25fc6517de6a25587eae4133/rpds_py-0.28.0-cp311-cp311-win32.whl", hash = "sha256:5cfa9af45e7c1140af7321fa0bef25b386ee9faa8928c80dc3a5360971a29e8c", size = 214729 }, - { url = "https://files.pythonhosted.org/packages/12/fd/b0f48c4c320ee24c8c20df8b44acffb7353991ddf688af01eef5f93d7018/rpds_py-0.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd8d86b5d29d1b74100982424ba53e56033dc47720a6de9ba0259cf81d7cecaa", size = 223977 }, - { url = "https://files.pythonhosted.org/packages/b4/21/c8e77a2ac66e2ec4e21f18a04b4e9a0417ecf8e61b5eaeaa9360a91713b4/rpds_py-0.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:4e27d3a5709cc2b3e013bf93679a849213c79ae0573f9b894b284b55e729e120", size = 217326 }, - { url = "https://files.pythonhosted.org/packages/b8/5c/6c3936495003875fe7b14f90ea812841a08fca50ab26bd840e924097d9c8/rpds_py-0.28.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:6b4f28583a4f247ff60cd7bdda83db8c3f5b05a7a82ff20dd4b078571747708f", size = 366439 }, - { url = "https://files.pythonhosted.org/packages/56/f9/a0f1ca194c50aa29895b442771f036a25b6c41a35e4f35b1a0ea713bedae/rpds_py-0.28.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d678e91b610c29c4b3d52a2c148b641df2b4676ffe47c59f6388d58b99cdc424", size = 348170 }, - { url = "https://files.pythonhosted.org/packages/18/ea/42d243d3a586beb72c77fa5def0487daf827210069a95f36328e869599ea/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e819e0e37a44a78e1383bf1970076e2ccc4dc8c2bbaa2f9bd1dc987e9afff628", size = 378838 }, - { url = "https://files.pythonhosted.org/packages/e7/78/3de32e18a94791af8f33601402d9d4f39613136398658412a4e0b3047327/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5ee514e0f0523db5d3fb171f397c54875dbbd69760a414dccf9d4d7ad628b5bd", size = 393299 }, - { url = "https://files.pythonhosted.org/packages/13/7e/4bdb435afb18acea2eb8a25ad56b956f28de7c59f8a1d32827effa0d4514/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3fa06d27fdcee47f07a39e02862da0100cb4982508f5ead53ec533cd5fe55e", size = 518000 }, - { url = "https://files.pythonhosted.org/packages/31/d0/5f52a656875cdc60498ab035a7a0ac8f399890cc1ee73ebd567bac4e39ae/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46959ef2e64f9e4a41fc89aa20dbca2b85531f9a72c21099a3360f35d10b0d5a", size = 408746 }, - { url = "https://files.pythonhosted.org/packages/3e/cd/49ce51767b879cde77e7ad9fae164ea15dce3616fe591d9ea1df51152706/rpds_py-0.28.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8455933b4bcd6e83fde3fefc987a023389c4b13f9a58c8d23e4b3f6d13f78c84", size = 386379 }, - { url = "https://files.pythonhosted.org/packages/6a/99/e4e1e1ee93a98f72fc450e36c0e4d99c35370220e815288e3ecd2ec36a2a/rpds_py-0.28.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ad50614a02c8c2962feebe6012b52f9802deec4263946cddea37aaf28dd25a66", size = 401280 }, - { url = "https://files.pythonhosted.org/packages/61/35/e0c6a57488392a8b319d2200d03dad2b29c0db9996f5662c3b02d0b86c02/rpds_py-0.28.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5deca01b271492553fdb6c7fd974659dce736a15bae5dad7ab8b93555bceb28", size = 412365 }, - { url = "https://files.pythonhosted.org/packages/ff/6a/841337980ea253ec797eb084665436007a1aad0faac1ba097fb906c5f69c/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:735f8495a13159ce6a0d533f01e8674cec0c57038c920495f87dcb20b3ddb48a", size = 559573 }, - { url = "https://files.pythonhosted.org/packages/e7/5e/64826ec58afd4c489731f8b00729c5f6afdb86f1df1df60bfede55d650bb/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:961ca621ff10d198bbe6ba4957decca61aa2a0c56695384c1d6b79bf61436df5", size = 583973 }, - { url = "https://files.pythonhosted.org/packages/b6/ee/44d024b4843f8386a4eeaa4c171b3d31d55f7177c415545fd1a24c249b5d/rpds_py-0.28.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2374e16cc9131022e7d9a8f8d65d261d9ba55048c78f3b6e017971a4f5e6353c", size = 553800 }, - { url = "https://files.pythonhosted.org/packages/7d/89/33e675dccff11a06d4d85dbb4d1865f878d5020cbb69b2c1e7b2d3f82562/rpds_py-0.28.0-cp312-cp312-win32.whl", hash = "sha256:d15431e334fba488b081d47f30f091e5d03c18527c325386091f31718952fe08", size = 216954 }, - { url = "https://files.pythonhosted.org/packages/af/36/45f6ebb3210887e8ee6dbf1bc710ae8400bb417ce165aaf3024b8360d999/rpds_py-0.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:a410542d61fc54710f750d3764380b53bf09e8c4edbf2f9141a82aa774a04f7c", size = 227844 }, - { url = "https://files.pythonhosted.org/packages/57/91/f3fb250d7e73de71080f9a221d19bd6a1c1eb0d12a1ea26513f6c1052ad6/rpds_py-0.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:1f0cfd1c69e2d14f8c892b893997fa9a60d890a0c8a603e88dca4955f26d1edd", size = 217624 }, - { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235 }, - { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241 }, - { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079 }, - { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151 }, - { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520 }, - { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699 }, - { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720 }, - { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096 }, - { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465 }, - { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832 }, - { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230 }, - { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268 }, - { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100 }, - { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759 }, - { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326 }, - { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736 }, - { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677 }, - { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847 }, - { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800 }, - { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827 }, - { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471 }, - { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578 }, - { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482 }, - { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447 }, - { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385 }, - { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642 }, - { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507 }, - { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376 }, - { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907 }, - { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830 }, - { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819 }, - { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127 }, - { url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767 }, - { url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585 }, - { url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828 }, - { url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509 }, - { url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014 }, - { url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410 }, - { url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593 }, - { url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925 }, - { url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444 }, - { url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968 }, - { url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876 }, - { url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506 }, - { url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433 }, - { url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601 }, - { url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039 }, - { url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407 }, - { url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172 }, - { url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020 }, - { url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451 }, - { url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355 }, - { url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146 }, - { url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656 }, - { url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782 }, - { url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671 }, - { url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749 }, - { url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233 }, - { url = "https://files.pythonhosted.org/packages/ae/bc/b43f2ea505f28119bd551ae75f70be0c803d2dbcd37c1b3734909e40620b/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f5e7101145427087e493b9c9b959da68d357c28c562792300dd21a095118ed16", size = 363913 }, - { url = "https://files.pythonhosted.org/packages/28/f2/db318195d324c89a2c57dc5195058cbadd71b20d220685c5bd1da79ee7fe/rpds_py-0.28.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:31eb671150b9c62409a888850aaa8e6533635704fe2b78335f9aaf7ff81eec4d", size = 350452 }, - { url = "https://files.pythonhosted.org/packages/ae/f2/1391c819b8573a4898cedd6b6c5ec5bc370ce59e5d6bdcebe3c9c1db4588/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b55c1f64482f7d8bd39942f376bfdf2f6aec637ee8c805b5041e14eeb771db", size = 380957 }, - { url = "https://files.pythonhosted.org/packages/5a/5c/e5de68ee7eb7248fce93269833d1b329a196d736aefb1a7481d1e99d1222/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24743a7b372e9a76171f6b69c01aedf927e8ac3e16c474d9fe20d552a8cb45c7", size = 391919 }, - { url = "https://files.pythonhosted.org/packages/fb/4f/2376336112cbfeb122fd435d608ad8d5041b3aed176f85a3cb32c262eb80/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:389c29045ee8bbb1627ea190b4976a310a295559eaf9f1464a1a6f2bf84dde78", size = 528541 }, - { url = "https://files.pythonhosted.org/packages/68/53/5ae232e795853dd20da7225c5dd13a09c0a905b1a655e92bdf8d78a99fd9/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23690b5827e643150cf7b49569679ec13fe9a610a15949ed48b85eb7f98f34ec", size = 405629 }, - { url = "https://files.pythonhosted.org/packages/b9/2d/351a3b852b683ca9b6b8b38ed9efb2347596973849ba6c3a0e99877c10aa/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f0c9266c26580e7243ad0d72fc3e01d6b33866cfab5084a6da7576bcf1c4f72", size = 384123 }, - { url = "https://files.pythonhosted.org/packages/e0/15/870804daa00202728cc91cb8e2385fa9f1f4eb49857c49cfce89e304eae6/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:4c6c4db5d73d179746951486df97fd25e92396be07fc29ee8ff9a8f5afbdfb27", size = 400923 }, - { url = "https://files.pythonhosted.org/packages/53/25/3706b83c125fa2a0bccceac951de3f76631f6bd0ee4d02a0ed780712ef1b/rpds_py-0.28.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3b695a8fa799dd2cfdb4804b37096c5f6dba1ac7f48a7fbf6d0485bcd060316", size = 413767 }, - { url = "https://files.pythonhosted.org/packages/ef/f9/ce43dbe62767432273ed2584cef71fef8411bddfb64125d4c19128015018/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:6aa1bfce3f83baf00d9c5fcdbba93a3ab79958b4c7d7d1f55e7fe68c20e63912", size = 561530 }, - { url = "https://files.pythonhosted.org/packages/46/c9/ffe77999ed8f81e30713dd38fd9ecaa161f28ec48bb80fa1cd9118399c27/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:7b0f9dceb221792b3ee6acb5438eb1f02b0cb2c247796a72b016dcc92c6de829", size = 585453 }, - { url = "https://files.pythonhosted.org/packages/ed/d2/4a73b18821fd4669762c855fd1f4e80ceb66fb72d71162d14da58444a763/rpds_py-0.28.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:5d0145edba8abd3db0ab22b5300c99dc152f5c9021fab861be0f0544dc3cbc5f", size = 552199 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "soupsieve" -version = "2.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679 }, -] - -[[package]] -name = "sse-starlette" -version = "3.0.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765 }, -] - -[[package]] -name = "starlette" -version = "0.50.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033 }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, -] - -[[package]] -name = "uvicorn" -version = "0.38.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "h11" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109 }, -] diff --git a/third_party/agfs/agfs-sdk/go/README.md b/third_party/agfs/agfs-sdk/go/README.md deleted file mode 100644 index dd3123383..000000000 --- a/third_party/agfs/agfs-sdk/go/README.md +++ /dev/null @@ -1,180 +0,0 @@ -# AGFS Go SDK - -Go client SDK for AGFS (Abstract Global File System) HTTP API. This SDK provides a simple and idiomatic Go interface for interacting with AGFS servers. - -## Installation - -Add the SDK to your project using `go get`: - -```bash -go get github.com/c4pt0r/agfs/agfs-sdk/go -``` - -## Quickstart - -Here is a complete example showing how to connect to an AGFS server and perform basic file operations. - -```go -package main - -import ( - "fmt" - "log" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" -) - -func main() { - // 1. Initialize the client - // You can point to the base URL (e.g., http://localhost:8080) - client := agfs.NewClient("http://localhost:8080") - - // 2. Check server health - if err := client.Health(); err != nil { - log.Fatalf("Server is not healthy: %v", err) - } - fmt.Println("Connected to AGFS server") - - // 3. Write data to a file (creates the file if it doesn't exist) - filePath := "/hello.txt" - content := []byte("Hello, AGFS!") - if _, err := client.Write(filePath, content); err != nil { - log.Fatalf("Failed to write file: %v", err) - } - fmt.Printf("Successfully wrote to %s\n", filePath) - - // 4. Read the file back - readData, err := client.Read(filePath, 0, -1) // -1 reads the whole file - if err != nil { - log.Fatalf("Failed to read file: %v", err) - } - fmt.Printf("Read content: %s\n", string(readData)) - - // 5. Get file metadata - info, err := client.Stat(filePath) - if err != nil { - log.Fatalf("Failed to stat file: %v", err) - } - fmt.Printf("File info: Size=%d, ModTime=%s\n", info.Size, info.ModTime) - - // 6. Clean up - if err := client.Remove(filePath); err != nil { - log.Printf("Failed to remove file: %v", err) - } - fmt.Println("File removed") -} -``` - -## Usage Guide - -### Client Initialization - -You can create a client using `NewClient`. The SDK automatically handles the `/api/v1` path suffix if omitted. - -```go -// Connect to localhost -client := agfs.NewClient("http://localhost:8080") -``` - -For advanced configuration (e.g., custom timeouts, TLS), use `NewClientWithHTTPClient`: - -```go -httpClient := &http.Client{ - Timeout: 30 * time.Second, -} -client := agfs.NewClientWithHTTPClient("http://localhost:8080", httpClient) -``` - -### File Operations - -#### Read and Write -The `Write` method includes automatic retries with exponential backoff for network errors. - -```go -// Write data -msg, err := client.Write("/logs/app.log", []byte("application started")) - -// Read entire file -data, err := client.Read("/logs/app.log", 0, -1) - -// Read partial content (e.g., first 100 bytes) -header, err := client.Read("/logs/app.log", 0, 100) -``` - -#### Manage Files -```go -// Create an empty file -err := client.Create("/newfile.txt") - -// Rename or move a file -err := client.Rename("/newfile.txt", "/archive/oldfile.txt") - -// Change permissions -err := client.Chmod("/script.sh", 0755) - -// Delete a file -err := client.Remove("/archive/oldfile.txt") -``` - -### Directory Operations - -```go -// Create a directory -err := client.Mkdir("/data/images", 0755) - -// List directory contents -files, err := client.ReadDir("/data/images") -for _, f := range files { - fmt.Printf("%s (Dir: %v, Size: %d)\n", f.Name, f.IsDir, f.Size) -} - -// Remove a directory recursively -err := client.RemoveAll("/data") -``` - -### Advanced Features - -#### Streaming -For large files, use `ReadStream` to process data without loading it all into memory. - -```go -reader, err := client.ReadStream("/large-video.mp4") -if err != nil { - log.Fatal(err) -} -defer reader.Close() - -io.Copy(localFile, reader) -``` - -#### Server-Side Search (Grep) -Perform regex searches directly on the server. - -```go -// Recursive search for "error" in /var/logs, case-insensitive -results, err := client.Grep("/var/logs", "error", true, true) -for _, match := range results.Matches { - fmt.Printf("%s:%d: %s\n", match.File, match.Line, match.Content) -} -``` - -#### Checksums -Calculate file digests on the server side. - -```go -// Calculate xxHash3 (or "md5") -resp, err := client.Digest("/iso/installer.iso", "xxh3") -fmt.Printf("Digest: %s\n", resp.Digest) -``` - -## Testing - -To run the SDK tests: - -```bash -go test -v -``` - -## License - -See the LICENSE file in the root of the repository. \ No newline at end of file diff --git a/third_party/agfs/agfs-sdk/go/client.go b/third_party/agfs/agfs-sdk/go/client.go deleted file mode 100644 index 7e6709933..000000000 --- a/third_party/agfs/agfs-sdk/go/client.go +++ /dev/null @@ -1,930 +0,0 @@ -package agfs - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" -) - -// Common errors -var ( - // ErrNotSupported is returned when the server or endpoint does not support the requested operation (HTTP 501) - ErrNotSupported = fmt.Errorf("operation not supported") -) - -// Client is a Go client for AGFS HTTP API -type Client struct { - baseURL string - httpClient *http.Client -} - -// NewClient creates a new AGFS client -// baseURL can be either full URL with "/api/v1" or just the base. -// If "/api/v1" is not present, it will be automatically appended. -// e.g., "http://localhost:8080" or "http://localhost:8080/api/v1" -func NewClient(baseURL string) *Client { - return &Client{ - baseURL: normalizeBaseURL(baseURL), - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -// NewClientWithHTTPClient creates a new AGFS client with custom HTTP client -func NewClientWithHTTPClient(baseURL string, httpClient *http.Client) *Client { - return &Client{ - baseURL: normalizeBaseURL(baseURL), - httpClient: httpClient, - } -} - -// normalizeBaseURL ensures the base URL ends with /api/v1 -func normalizeBaseURL(baseURL string) string { - // Remove trailing slash - if len(baseURL) > 0 && baseURL[len(baseURL)-1] == '/' { - baseURL = baseURL[:len(baseURL)-1] - } - - // Validate that we have a proper URL with a host - // A valid URL should at least have "protocol://host" format - // Check for "://" to ensure we have both protocol and host - if !strings.Contains(baseURL, "://") { - // If there's no "://", this is likely a malformed URL - // Don't try to fix it, just return as-is and let HTTP client fail with proper error - return baseURL - } - - // Auto-append /api/v1 if not present - if len(baseURL) < 7 || baseURL[len(baseURL)-7:] != "/api/v1" { - baseURL = baseURL + "/api/v1" - } - return baseURL -} - -// ErrorResponse represents an error response from the API -type ErrorResponse struct { - Error string `json:"error"` -} - -// SuccessResponse represents a success response from the API -type SuccessResponse struct { - Message string `json:"message"` -} - -// FileInfoResponse represents file info response from the API -type FileInfoResponse struct { - Name string `json:"name"` - Size int64 `json:"size"` - Mode uint32 `json:"mode"` - ModTime string `json:"modTime"` - IsDir bool `json:"isDir"` - Meta MetaData `json:"meta,omitempty"` -} - -// ListResponse represents directory listing response from the API -type ListResponse struct { - Files []FileInfoResponse `json:"files"` -} - -// RenameRequest represents a rename request -type RenameRequest struct { - NewPath string `json:"newPath"` -} - -// ChmodRequest represents a chmod request -type ChmodRequest struct { - Mode uint32 `json:"mode"` -} - -func (c *Client) doRequest(method, endpoint string, query url.Values, body io.Reader) (*http.Response, error) { - u := c.baseURL + endpoint - if len(query) > 0 { - u += "?" + query.Encode() - } - - req, err := http.NewRequest(method, u, body) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - - return resp, nil -} - -func (c *Client) handleErrorResponse(resp *http.Response) error { - defer resp.Body.Close() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - - if resp.StatusCode == http.StatusNotImplemented { - return ErrNotSupported - } - - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) -} - -// Create creates a new file -func (c *Client) Create(path string) error { - query := url.Values{} - query.Set("path", path) - - resp, err := c.doRequest(http.MethodPost, "/files", query, nil) - if err != nil { - return err - } - - return c.handleErrorResponse(resp) -} - -// Mkdir creates a new directory -func (c *Client) Mkdir(path string, perm uint32) error { - query := url.Values{} - query.Set("path", path) - query.Set("mode", fmt.Sprintf("%o", perm)) - - resp, err := c.doRequest(http.MethodPost, "/directories", query, nil) - if err != nil { - return err - } - - return c.handleErrorResponse(resp) -} - -// Remove removes a file or empty directory -func (c *Client) Remove(path string) error { - query := url.Values{} - query.Set("path", path) - query.Set("recursive", "false") - - resp, err := c.doRequest(http.MethodDelete, "/files", query, nil) - if err != nil { - return err - } - - return c.handleErrorResponse(resp) -} - -// RemoveAll removes a path and any children it contains -func (c *Client) RemoveAll(path string) error { - query := url.Values{} - query.Set("path", path) - query.Set("recursive", "true") - - resp, err := c.doRequest(http.MethodDelete, "/files", query, nil) - if err != nil { - return err - } - - return c.handleErrorResponse(resp) -} - -// Read reads file content with optional offset and size -// offset: starting position (0 means from beginning) -// size: number of bytes to read (-1 means read all) -// Returns io.EOF if offset+size >= file size (reached end of file) -func (c *Client) Read(path string, offset int64, size int64) ([]byte, error) { - query := url.Values{} - query.Set("path", path) - if offset > 0 { - query.Set("offset", fmt.Sprintf("%d", offset)) - } - if size >= 0 { - query.Set("size", fmt.Sprintf("%d", size)) - } - - resp, err := c.doRequest(http.MethodGet, "/files", query, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - return data, nil -} - -// Write writes data to a file, creating it if necessary -// Automatically retries on network errors and timeouts (max 3 retries with exponential backoff) -func (c *Client) Write(path string, data []byte) ([]byte, error) { - return c.WriteWithRetry(path, data, 3) -} - -// WriteWithRetry writes data to a file with configurable retry attempts -func (c *Client) WriteWithRetry(path string, data []byte, maxRetries int) ([]byte, error) { - query := url.Values{} - query.Set("path", path) - - var lastErr error - - for attempt := 0; attempt <= maxRetries; attempt++ { - resp, err := c.doRequest(http.MethodPut, "/files", query, bytes.NewReader(data)) - if err != nil { - lastErr = err - - // Check if error is retryable (network/timeout errors) - if isRetryableError(err) && attempt < maxRetries { - waitTime := time.Duration(1<<uint(attempt)) * time.Second // 1s, 2s, 4s - fmt.Printf("⚠ Upload failed (attempt %d/%d): %v\n", attempt+1, maxRetries+1, err) - fmt.Printf(" Retrying in %v...\n", waitTime) - time.Sleep(waitTime) - continue - } - - if attempt >= maxRetries { - fmt.Printf("✗ Upload failed after %d attempts\n", maxRetries+1) - } - return nil, err - } - - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - - lastErr = fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - - // Retry on server errors (5xx) - if resp.StatusCode >= 500 && resp.StatusCode < 600 && attempt < maxRetries { - waitTime := time.Duration(1<<uint(attempt)) * time.Second - fmt.Printf("⚠ Server error %d (attempt %d/%d)\n", resp.StatusCode, attempt+1, maxRetries+1) - fmt.Printf(" Retrying in %v...\n", waitTime) - time.Sleep(waitTime) - continue - } - - if attempt >= maxRetries { - fmt.Printf("✗ Upload failed after %d attempts\n", maxRetries+1) - } - return nil, lastErr - } - - var successResp SuccessResponse - if err := json.NewDecoder(resp.Body).Decode(&successResp); err != nil { - return nil, fmt.Errorf("failed to decode success response: %w", err) - } - - // If we succeeded after retrying, let user know - if attempt > 0 { - fmt.Printf("✓ Upload succeeded after %d retry(ies)\n", attempt) - } - - return []byte(successResp.Message), nil - } - - return nil, lastErr -} - -// isRetryableError checks if an error is retryable (network/timeout errors) -func isRetryableError(err error) bool { - if err == nil { - return false - } - - // Check for timeout errors - if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { - return true - } - - // Check for temporary network errors - if netErr, ok := err.(interface{ Temporary() bool }); ok && netErr.Temporary() { - return true - } - - // Check for connection errors - errStr := err.Error() - return strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "connection reset") || - strings.Contains(errStr, "broken pipe") || - strings.Contains(errStr, "timeout") -} - -// ReadDir lists the contents of a directory -func (c *Client) ReadDir(path string) ([]FileInfo, error) { - query := url.Values{} - query.Set("path", path) - - resp, err := c.doRequest(http.MethodGet, "/directories", query, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var listResp ListResponse - if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil { - return nil, fmt.Errorf("failed to decode list response: %w", err) - } - - files := make([]FileInfo, 0, len(listResp.Files)) - for _, f := range listResp.Files { - modTime, _ := time.Parse(time.RFC3339Nano, f.ModTime) - files = append(files, FileInfo{ - Name: f.Name, - Size: f.Size, - Mode: f.Mode, - ModTime: modTime, - IsDir: f.IsDir, - Meta: f.Meta, - }) - } - - return files, nil -} - -// Stat returns file information -func (c *Client) Stat(path string) (*FileInfo, error) { - query := url.Values{} - query.Set("path", path) - - resp, err := c.doRequest(http.MethodGet, "/stat", query, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var fileInfo FileInfoResponse - if err := json.NewDecoder(resp.Body).Decode(&fileInfo); err != nil { - return nil, fmt.Errorf("failed to decode file info response: %w", err) - } - - modTime, _ := time.Parse(time.RFC3339Nano, fileInfo.ModTime) - - return &FileInfo{ - Name: fileInfo.Name, - Size: fileInfo.Size, - Mode: fileInfo.Mode, - ModTime: modTime, - IsDir: fileInfo.IsDir, - Meta: fileInfo.Meta, - }, nil -} - -// Rename renames/moves a file or directory -func (c *Client) Rename(oldPath, newPath string) error { - query := url.Values{} - query.Set("path", oldPath) - - reqBody := RenameRequest{NewPath: newPath} - jsonData, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("failed to marshal rename request: %w", err) - } - - resp, err := c.doRequest(http.MethodPost, "/rename", query, bytes.NewReader(jsonData)) - if err != nil { - return err - } - - return c.handleErrorResponse(resp) -} - -// Chmod changes file permissions -func (c *Client) Chmod(path string, mode uint32) error { - query := url.Values{} - query.Set("path", path) - - reqBody := ChmodRequest{Mode: mode} - jsonData, err := json.Marshal(reqBody) - if err != nil { - return fmt.Errorf("failed to marshal chmod request: %w", err) - } - - resp, err := c.doRequest(http.MethodPost, "/chmod", query, bytes.NewReader(jsonData)) - if err != nil { - return err - } - - return c.handleErrorResponse(resp) -} - -// Health checks the health of the AGFS server -func (c *Client) Health() error { - resp, err := c.doRequest(http.MethodGet, "/health", nil, nil) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("health check failed with status: %d", resp.StatusCode) - } - - return nil -} - -// CapabilitiesResponse represents the server capabilities -type CapabilitiesResponse struct { - Version string `json:"version"` - Features []string `json:"features"` -} - -// GetCapabilities retrieves the server capabilities -func (c *Client) GetCapabilities() (*CapabilitiesResponse, error) { - resp, err := c.doRequest(http.MethodGet, "/capabilities", nil, nil) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - // Fallback for older servers that don't have this endpoint - if resp.StatusCode == http.StatusNotFound { - return &CapabilitiesResponse{ - Version: "unknown", - Features: []string{}, - }, nil - } - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var caps CapabilitiesResponse - if err := json.NewDecoder(resp.Body).Decode(&caps); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &caps, nil -} - -// ReadStream opens a streaming connection to read from a file -// Returns an io.ReadCloser that streams data from the server -// The caller is responsible for closing the reader -func (c *Client) ReadStream(path string) (io.ReadCloser, error) { - query := url.Values{} - query.Set("path", path) - query.Set("stream", "true") // Enable streaming mode - - // Create request with no timeout for streaming - streamClient := &http.Client{ - Timeout: 0, // No timeout for streaming - } - - reqURL := fmt.Sprintf("%s/files?%s", c.baseURL, query.Encode()) - req, err := http.NewRequest(http.MethodGet, reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := streamClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - - if resp.StatusCode != http.StatusOK { - defer resp.Body.Close() - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - // Return the response body as a ReadCloser - // Caller must close it when done - return resp.Body, nil -} - -// GrepRequest represents a grep search request -type GrepRequest struct { - Path string `json:"path"` - Pattern string `json:"pattern"` - Recursive bool `json:"recursive"` - CaseInsensitive bool `json:"case_insensitive"` - NodeLimit int `json:"node_limit"` // Maximum number of results to return (0 means no limit) -} - -// GrepMatch represents a single match result -type GrepMatch struct { - File string `json:"file"` - Line int `json:"line"` - Content string `json:"content"` -} - -// GrepResponse represents the grep search results -type GrepResponse struct { - Matches []GrepMatch `json:"matches"` - Count int `json:"count"` -} - -// DigestRequest represents a digest request -type DigestRequest struct { - Algorithm string `json:"algorithm"` // "xxh3" or "md5" - Path string `json:"path"` // Path to the file -} - -// DigestResponse represents the digest result -type DigestResponse struct { - Algorithm string `json:"algorithm"` // Algorithm used - Path string `json:"path"` // File path - Digest string `json:"digest"` // Hex-encoded digest -} - -// Grep searches for a pattern in files using regular expressions -func (c *Client) Grep(path, pattern string, recursive, caseInsensitive bool, nodeLimit int) (*GrepResponse, error) { - nl := 0 - if nodeLimit > 0 { - nl = nodeLimit - } - reqBody := GrepRequest{ - Path: path, - Pattern: pattern, - Recursive: recursive, - CaseInsensitive: caseInsensitive, - NodeLimit: nl, - } - - body, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - reqURL := fmt.Sprintf("%s/grep", c.baseURL) - req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var grepResp GrepResponse - if err := json.NewDecoder(resp.Body).Decode(&grepResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &grepResp, nil -} - -// Digest calculates the digest of a file using specified algorithm -func (c *Client) Digest(path, algorithm string) (*DigestResponse, error) { - reqBody := DigestRequest{ - Algorithm: algorithm, - Path: path, - } - - body, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - reqURL := fmt.Sprintf("%s/digest", c.baseURL) - req, err := http.NewRequest(http.MethodPost, reqURL, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var digestResp DigestResponse - if err := json.NewDecoder(resp.Body).Decode(&digestResp); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &digestResp, nil -} - -// OpenHandle opens a file and returns a handle ID -func (c *Client) OpenHandle(path string, flags OpenFlag, mode uint32) (int64, error) { - query := url.Values{} - query.Set("path", path) - query.Set("flags", fmt.Sprintf("%d", flags)) - query.Set("mode", fmt.Sprintf("%o", mode)) - - resp, err := c.doRequest(http.MethodPost, "/handles/open", query, nil) - if err != nil { - return 0, fmt.Errorf("open handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - if resp.StatusCode == http.StatusNotImplemented { - return 0, ErrNotSupported - } - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return 0, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var handleResp HandleResponse - if err := json.NewDecoder(resp.Body).Decode(&handleResp); err != nil { - return 0, fmt.Errorf("failed to decode handle response: %w", err) - } - - return handleResp.HandleID, nil -} - -// CloseHandle closes a file handle -func (c *Client) CloseHandle(handleID int64) error { - endpoint := fmt.Sprintf("/handles/%d", handleID) - - resp, err := c.doRequest(http.MethodDelete, endpoint, nil, nil) - if err != nil { - return fmt.Errorf("close handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - return nil -} - -// ReadHandle reads data from a file handle -func (c *Client) ReadHandle(handleID int64, offset int64, size int) ([]byte, error) { - endpoint := fmt.Sprintf("/handles/%d/read", handleID) - query := url.Values{} - query.Set("offset", fmt.Sprintf("%d", offset)) - query.Set("size", fmt.Sprintf("%d", size)) - - resp, err := c.doRequest(http.MethodGet, endpoint, query, nil) - if err != nil { - return nil, fmt.Errorf("read handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - - return data, nil -} - -// ReadHandleStream opens a streaming connection to read from a file handle -// Returns an io.ReadCloser that streams data from the server -// The caller is responsible for closing the reader -func (c *Client) ReadHandleStream(handleID int64) (io.ReadCloser, error) { - endpoint := fmt.Sprintf("/handles/%d/stream", handleID) - - // Create request with no timeout for streaming - streamClient := &http.Client{ - Timeout: 0, // No timeout for streaming - } - - reqURL := fmt.Sprintf("%s%s", c.baseURL, endpoint) - req, err := http.NewRequest(http.MethodGet, reqURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := streamClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to execute request: %w", err) - } - - if resp.StatusCode != http.StatusOK { - defer resp.Body.Close() - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - return resp.Body, nil -} - -// WriteHandle writes data to a file handle -func (c *Client) WriteHandle(handleID int64, data []byte, offset int64) (int, error) { - endpoint := fmt.Sprintf("/handles/%d/write", handleID) - query := url.Values{} - query.Set("offset", fmt.Sprintf("%d", offset)) - - // Note: For binary data, we don't use JSON - req, err := http.NewRequest(http.MethodPut, c.baseURL+endpoint+"?"+query.Encode(), bytes.NewReader(data)) - if err != nil { - return 0, fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/octet-stream") - - resp, err := c.httpClient.Do(req) - if err != nil { - return 0, fmt.Errorf("write handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return 0, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - // Parse bytes written from response - var result struct { - BytesWritten int `json:"bytes_written"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - // If parsing fails, assume all bytes were written - return len(data), nil - } - - return result.BytesWritten, nil -} - -// SyncHandle syncs a file handle -func (c *Client) SyncHandle(handleID int64) error { - endpoint := fmt.Sprintf("/handles/%d/sync", handleID) - - resp, err := c.doRequest(http.MethodPost, endpoint, nil, nil) - if err != nil { - return fmt.Errorf("sync handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - return nil -} - -// SeekHandle seeks to a position in a file handle -func (c *Client) SeekHandle(handleID int64, offset int64, whence int) (int64, error) { - endpoint := fmt.Sprintf("/handles/%d/seek", handleID) - query := url.Values{} - query.Set("offset", fmt.Sprintf("%d", offset)) - query.Set("whence", fmt.Sprintf("%d", whence)) - - resp, err := c.doRequest(http.MethodPost, endpoint, query, nil) - if err != nil { - return 0, fmt.Errorf("seek handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return 0, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return 0, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var result struct { - Offset int64 `json:"offset"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return 0, fmt.Errorf("failed to decode response: %w", err) - } - - return result.Offset, nil -} - -// GetHandle retrieves information about an open handle -func (c *Client) GetHandle(handleID int64) (*HandleInfo, error) { - endpoint := fmt.Sprintf("/handles/%d", handleID) - - resp, err := c.doRequest(http.MethodGet, endpoint, nil, nil) - if err != nil { - return nil, fmt.Errorf("get handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var handleInfo HandleInfo - if err := json.NewDecoder(resp.Body).Decode(&handleInfo); err != nil { - return nil, fmt.Errorf("failed to decode handle info: %w", err) - } - - return &handleInfo, nil -} - -// StatHandle gets file info via a handle -func (c *Client) StatHandle(handleID int64) (*FileInfo, error) { - endpoint := fmt.Sprintf("/handles/%d/stat", handleID) - - resp, err := c.doRequest(http.MethodGet, endpoint, nil, nil) - if err != nil { - return nil, fmt.Errorf("stat handle request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var errResp ErrorResponse - if err := json.NewDecoder(resp.Body).Decode(&errResp); err != nil { - return nil, fmt.Errorf("HTTP %d: failed to decode error response", resp.StatusCode) - } - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, errResp.Error) - } - - var fileInfo FileInfoResponse - if err := json.NewDecoder(resp.Body).Decode(&fileInfo); err != nil { - return nil, fmt.Errorf("failed to decode file info response: %w", err) - } - - modTime, _ := time.Parse(time.RFC3339Nano, fileInfo.ModTime) - - return &FileInfo{ - Name: fileInfo.Name, - Size: fileInfo.Size, - Mode: fileInfo.Mode, - ModTime: modTime, - IsDir: fileInfo.IsDir, - Meta: fileInfo.Meta, - }, nil -} diff --git a/third_party/agfs/agfs-sdk/go/client_test.go b/third_party/agfs/agfs-sdk/go/client_test.go deleted file mode 100644 index d31da5732..000000000 --- a/third_party/agfs/agfs-sdk/go/client_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package agfs - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strconv" - "testing" -) - -func TestClient_Create(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/api/v1/files" { - t.Errorf("expected /api/v1/files, got %s", r.URL.Path) - } - if r.URL.Query().Get("path") != "/test/file.txt" { - t.Errorf("expected path=/test/file.txt, got %s", r.URL.Query().Get("path")) - } - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(SuccessResponse{Message: "file created"}) - })) - defer server.Close() - - client := NewClient(server.URL) - err := client.Create("/test/file.txt") - if err != nil { - t.Errorf("Create failed: %v", err) - } -} - -func TestClient_Read(t *testing.T) { - expectedData := []byte("hello world") - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - t.Errorf("expected GET, got %s", r.Method) - } - if r.URL.Path != "/api/v1/files" { - t.Errorf("expected /api/v1/files, got %s", r.URL.Path) - } - w.WriteHeader(http.StatusOK) - w.Write(expectedData) - })) - defer server.Close() - - client := NewClient(server.URL) - data, err := client.Read("/test/file.txt", 0, -1) - if err != nil { - t.Errorf("Read failed: %v", err) - } - if string(data) != string(expectedData) { - t.Errorf("expected %s, got %s", expectedData, data) - } -} - -func TestClient_Write(t *testing.T) { - testData := []byte("test content") - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - t.Errorf("expected PUT, got %s", r.Method) - } - if r.URL.Path != "/api/v1/files" { - t.Errorf("expected /api/v1/files, got %s", r.URL.Path) - } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(SuccessResponse{Message: "OK"}) - })) - defer server.Close() - - client := NewClient(server.URL) - response, err := client.Write("/test/file.txt", testData) - if err != nil { - t.Errorf("Write failed: %v", err) - } - if string(response) != "OK" { - t.Errorf("expected OK, got %s", response) - } -} - -func TestClient_Mkdir(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - t.Errorf("expected POST, got %s", r.Method) - } - if r.URL.Path != "/api/v1/directories" { - t.Errorf("expected /api/v1/directories, got %s", r.URL.Path) - } - if r.URL.Query().Get("mode") != "755" { - t.Errorf("expected mode=755, got %s", r.URL.Query().Get("mode")) - } - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(SuccessResponse{Message: "directory created"}) - })) - defer server.Close() - - client := NewClient(server.URL) - err := client.Mkdir("/test/dir", 0755) - if err != nil { - t.Errorf("Mkdir failed: %v", err) - } -} - -func TestClient_ErrorHandling(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotFound) - json.NewEncoder(w).Encode(ErrorResponse{Error: "file not found"}) - })) - defer server.Close() - - client := NewClient(server.URL) - _, err := client.Read("/nonexistent", 0, -1) - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestClient_OpenHandleNotSupported(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/v1/handles/open" { - w.WriteHeader(http.StatusNotImplemented) - json.NewEncoder(w).Encode(ErrorResponse{Error: "filesystem does not support file handles"}) - return - } - t.Errorf("unexpected request to %s", r.URL.Path) - })) - defer server.Close() - - client := NewClient(server.URL) - _, err := client.OpenHandle("/test/file.txt", 0, 0) - if err == nil { - t.Errorf("expected ErrNotSupported, got nil") - } - if err != ErrNotSupported { - t.Errorf("expected ErrNotSupported, got %v", err) - } -} - -func TestClient_OpenHandleModeOctalFormat(t *testing.T) { - tests := []struct { - name string - mode uint32 - expectedMode string // Expected octal string in query parameter - }{ - { - name: "mode 0644 (rw-r--r--)", - mode: 0644, - expectedMode: "644", - }, - { - name: "mode 0755 (rwxr-xr-x)", - mode: 0755, - expectedMode: "755", - }, - { - name: "mode 0100644 (regular file, rw-r--r--)", - mode: 0100644, // 33188 in decimal - expectedMode: "100644", - }, - { - name: "mode 0100755 (regular file, rwxr-xr-x)", - mode: 0100755, // 33261 in decimal - expectedMode: "100755", - }, - { - name: "mode 0600 (rw-------)", - mode: 0600, - expectedMode: "600", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/api/v1/handles/open" { - // Verify the mode parameter is in octal format - modeStr := r.URL.Query().Get("mode") - if modeStr != tt.expectedMode { - t.Errorf("mode parameter mismatch: expected %q (octal), got %q", tt.expectedMode, modeStr) - } - - // Verify the mode can be parsed as octal (like the server does) - if parsed, err := strconv.ParseUint(modeStr, 8, 32); err != nil { - t.Errorf("mode parameter %q cannot be parsed as octal: %v", modeStr, err) - } else if parsed != uint64(tt.mode) { - t.Errorf("parsed mode mismatch: expected %d, got %d", tt.mode, parsed) - } - - // Return success response - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(HandleResponse{HandleID: 123}) - return - } - t.Errorf("unexpected request to %s", r.URL.Path) - })) - defer server.Close() - - client := NewClient(server.URL) - handle, err := client.OpenHandle("/test/file.txt", 0, tt.mode) - if err != nil { - t.Errorf("OpenHandle failed: %v", err) - } - if handle != 123 { - t.Errorf("expected handle 123, got %d", handle) - } - }) - } -} - -func TestNormalizeBaseURL(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "full URL with /api/v1", - input: "http://localhost:8080/api/v1", - expected: "http://localhost:8080/api/v1", - }, - { - name: "URL without /api/v1", - input: "http://localhost:8080", - expected: "http://localhost:8080/api/v1", - }, - { - name: "URL with trailing slash", - input: "http://localhost:8080/", - expected: "http://localhost:8080/api/v1", - }, - { - name: "URL with /api/v1 and trailing slash", - input: "http://localhost:8080/api/v1/", - expected: "http://localhost:8080/api/v1", - }, - { - name: "malformed URL - just protocol", - input: "http:", - expected: "http:", // Don't try to fix it, return as-is - }, - { - name: "hostname with port", - input: "http://workstation:8080/api/v1", - expected: "http://workstation:8080/api/v1", - }, - { - name: "hostname with port no api path", - input: "http://workstation:8080", - expected: "http://workstation:8080/api/v1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := normalizeBaseURL(tt.input) - if result != tt.expected { - t.Errorf("normalizeBaseURL(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} diff --git a/third_party/agfs/agfs-sdk/go/go.mod b/third_party/agfs/agfs-sdk/go/go.mod deleted file mode 100644 index 8405299e2..000000000 --- a/third_party/agfs/agfs-sdk/go/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/c4pt0r/agfs/agfs-sdk/go - -go 1.19 diff --git a/third_party/agfs/agfs-sdk/go/types.go b/third_party/agfs/agfs-sdk/go/types.go deleted file mode 100644 index 6c90e3771..000000000 --- a/third_party/agfs/agfs-sdk/go/types.go +++ /dev/null @@ -1,46 +0,0 @@ -package agfs - -import "time" - -// MetaData represents structured metadata for files and directories -type MetaData struct { - Name string // Plugin name or identifier - Type string // Type classification of the file/directory - Content map[string]string // Additional extensible metadata -} - -// FileInfo represents file metadata similar to os.FileInfo -type FileInfo struct { - Name string - Size int64 - Mode uint32 - ModTime time.Time - IsDir bool - Meta MetaData // Structured metadata for additional information -} - -// OpenFlag represents file open flags -type OpenFlag int - -const ( - OpenFlagReadOnly OpenFlag = 0 - OpenFlagWriteOnly OpenFlag = 1 - OpenFlagReadWrite OpenFlag = 2 - OpenFlagAppend OpenFlag = 1024 - OpenFlagCreate OpenFlag = 64 - OpenFlagExclusive OpenFlag = 128 - OpenFlagTruncate OpenFlag = 512 - OpenFlagSync OpenFlag = 1052672 -) - -// HandleInfo represents an open file handle -type HandleInfo struct { - ID int64 `json:"id"` - Path string `json:"path"` - Flags OpenFlag `json:"flags"` -} - -// HandleResponse is the response for handle operations -type HandleResponse struct { - HandleID int64 `json:"handle_id"` -} diff --git a/third_party/agfs/agfs-sdk/python/README.md b/third_party/agfs/agfs-sdk/python/README.md deleted file mode 100644 index 968ad4cdc..000000000 --- a/third_party/agfs/agfs-sdk/python/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# pyagfs - AGFS Python SDK - -Python SDK for interacting with AGFS (Plugin-based File System) Server API. - -See more details at [c4pt0r/agfs](https://github.com/c4pt0r/agfs) - -## Installation - -```bash -pip install pyagfs -``` - -For local development: - -```bash -pip install -e . -``` - -## Quick Start - -```python -from pyagfs import AGFSClient - -# Initialize client -client = AGFSClient("http://localhost:8080") - -# Check server health -health = client.health() -print(f"Server version: {health.get('version', 'unknown')}") - -# List directory contents -files = client.ls("/") -for file in files: - print(f"{file['name']} - {'dir' if file['isDir'] else 'file'}") - -# Create a new directory -client.mkdir("/test_dir") - -# Write to a file -client.write("/test_dir/hello.txt", b"Hello, AGFS!") - -# Read file content -content = client.cat("/test_dir/hello.txt") -print(content.decode()) - -# Get file info -info = client.stat("/test_dir/hello.txt") -print(f"Size: {info['size']} bytes") - -# Remove file and directory -client.rm("/test_dir", recursive=True) -``` - -## High-Level File Operations - -The SDK provides helper functions for common operations like copying files within AGFS or transferring files between the local filesystem and AGFS. - -```python -from pyagfs import AGFSClient, cp, upload, download - -client = AGFSClient("http://localhost:8080") - -# Upload local file or directory to AGFS -upload(client, "./local_data", "/remote_data", recursive=True) - -# Download file or directory from AGFS to local -download(client, "/remote_data/config.json", "./local_config.json") - -# Copy files within AGFS -cp(client, "/remote_data/original.txt", "/remote_data/backup.txt") -``` - -## Advanced Usage - -### Streaming Operations - -Useful for handling large files or long-running search results. - -```python -# Stream file content -response = client.cat("/large/file.log", stream=True) -for chunk in response.iter_content(chunk_size=8192): - process(chunk) - -# Stream grep results -for match in client.grep("/logs", "error", recursive=True, stream=True): - if match.get('type') == 'summary': - print(f"Total matches: {match['count']}") - else: - print(f"{match['file']}:{match['line']}: {match['content']}") -``` - -### Mount Management - -Dynamically mount different filesystem backends. - -```python -# List mounted plugins -mounts = client.mounts() - -# Mount a memory filesystem -client.mount("memfs", "/test/mem", {}) - -# Mount a SQL filesystem -client.mount("sqlfs", "/test/db", { - "backend": "sqlite", - "db_path": "/tmp/test.db" -}) - -# Unmount a path -client.unmount("/test/mem") -``` - -### Plugin Management - -Load and unload external plugins (shared libraries). - -```python -# Load external plugin -result = client.load_plugin("./plugins/myplugin.so") - -# List loaded plugins -plugins = client.list_plugins() - -# Get detailed plugin info -plugin_infos = client.get_plugins_info() - -# Unload plugin -client.unload_plugin("./plugins/myplugin.so") -``` - -### Search and Integrity - -```python -# Recursive case-insensitive search -result = client.grep("/local", "warning|error", recursive=True, case_insensitive=True) -print(f"Found {result['count']} matches") - -# Calculate file digest (hash) -# Supported algorithms: "xxh3" (default), "md5" -result = client.digest("/path/to/file.txt", algorithm="xxh3") -print(f"Hash: {result['digest']}") -``` - -## API Reference - -### AGFSClient - -#### Constructor -- `AGFSClient(api_base_url, timeout=10)` - Initialize client with API base URL - -#### File Operations -- `ls(path="/")` - List directory contents -- `cat(path, offset=0, size=-1, stream=False)` - Read file content (alias: `read`) -- `write(path, data, max_retries=3)` - Write data to file with retry logic -- `create(path)` - Create new empty file -- `rm(path, recursive=False)` - Remove file or directory -- `stat(path)` - Get file/directory information -- `mv(old_path, new_path)` - Move/rename file or directory -- `chmod(path, mode)` - Change file permissions -- `touch(path)` - Update file timestamp -- `digest(path, algorithm="xxh3")` - Calculate file hash - -#### Directory Operations -- `mkdir(path, mode="755")` - Create directory - -#### Search Operations -- `grep(path, pattern, recursive=False, case_insensitive=False, stream=False)` - Search for pattern in files - -#### Mount Operations -- `mounts()` - List all mounted plugins -- `mount(fstype, path, config)` - Mount a plugin dynamically -- `unmount(path)` - Unmount a plugin - -#### Plugin Operations -- `list_plugins()` - List all loaded external plugins -- `get_plugins_info()` - Get detailed info about loaded plugins -- `load_plugin(library_path)` - Load an external plugin -- `unload_plugin(library_path)` - Unload an external plugin - -#### Health Check -- `health()` - Check server health - -### Helper Functions - -- `cp(client, src, dst, recursive=False, stream=False)` - Copy files/directories within AGFS -- `upload(client, local_path, remote_path, recursive=False, stream=False)` - Upload from local to AGFS -- `download(client, remote_path, local_path, recursive=False, stream=False)` - Download from AGFS to local - -## Development - -### Running Tests - -```bash -pip install -e ".[dev]" -pytest -``` - -### Code Formatting - -```bash -black pyagfs/ -ruff check pyagfs/ -``` - -## License - -See LICENSE file for details. \ No newline at end of file diff --git a/third_party/agfs/agfs-sdk/python/examples/advanced_usage.py b/third_party/agfs/agfs-sdk/python/examples/advanced_usage.py deleted file mode 100644 index a7d1fe730..000000000 --- a/third_party/agfs/agfs-sdk/python/examples/advanced_usage.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Advanced usage examples for pyagfs""" - -from pyagfs import AGFSClient, AGFSClientError -import time - - -def mount_example(client): - """Example of mounting plugins""" - print("=== Mount Management ===") - - # List current mounts - print("Current mounts:") - mounts = client.mounts() - for mount in mounts: - print(f" {mount['path']} -> {mount['pluginName']}") - print() - - # Mount a memory filesystem - mount_path = "/test_mem" - print(f"Mounting memfs at {mount_path}") - try: - client.mount("memfs", mount_path, {}) - print("Mount successful!") - except AGFSClientError as e: - print(f"Mount failed: {e}") - print() - - # Use the mounted filesystem - print("Testing mounted filesystem:") - test_file = f"{mount_path}/test.txt" - client.write(test_file, b"Data in memory filesystem") - content = client.cat(test_file) - print(f" Wrote and read: {content.decode()}") - print() - - # Unmount - print(f"Unmounting {mount_path}") - try: - client.unmount(mount_path) - print("Unmount successful!") - except AGFSClientError as e: - print(f"Unmount failed: {e}") - print() - - -def grep_example(client): - """Example of using grep functionality""" - print("=== Grep Search ===") - - # Create test files with content - test_dir = "/local/test_grep" - client.mkdir(test_dir) - - # Write test files - client.write(f"{test_dir}/file1.txt", b"This is a test file\nWith some error messages\n") - client.write(f"{test_dir}/file2.txt", b"Another test file\nNo issues here\n") - client.write(f"{test_dir}/file3.log", b"ERROR: Something went wrong\nWARNING: Be careful\n") - - # Search for pattern - print(f"Searching for 'error' in {test_dir}:") - result = client.grep(test_dir, "error", recursive=True, case_insensitive=True) - print(f"Found {result['count']} matches:") - for match in result['matches']: - print(f" {match['file']}:{match['line']}: {match['content'].strip()}") - print() - - # Clean up - client.rm(test_dir, recursive=True) - - -def streaming_example(client): - """Example of streaming operations""" - print("=== Streaming Operations ===") - - # Create a test file - test_file = "/streamfs/test_stream.txt" - large_content = b"Line %d\n" * 100 - lines = b"".join([b"Line %d\n" % i for i in range(100)]) - client.write(test_file, lines) - - # Stream read - print(f"Streaming read from {test_file} (first 5 chunks):") - response = client.cat(test_file, stream=True) - chunk_count = 0 - for chunk in response.iter_content(chunk_size=100): - if chunk_count < 5: - print(f" Chunk {chunk_count + 1}: {len(chunk)} bytes") - chunk_count += 1 - if chunk_count >= 5: - break - print(f" ... (total {chunk_count}+ chunks)") - print() - - # Clean up - client.rm(test_file) - - -def batch_operations(client): - """Example of batch file operations""" - print("=== Batch Operations ===") - - # Create multiple files - batch_dir = "/local/test_batch" - client.mkdir(batch_dir) - - print("Creating 10 files:") - for i in range(10): - filename = f"{batch_dir}/file_{i:02d}.txt" - client.write(filename, f"File number {i}".encode()) - print(f" Created {filename}") - print() - - # List all files - print(f"Files in {batch_dir}:") - files = client.ls(batch_dir) - for file in files: - info = client.stat(f"{batch_dir}/{file['name']}") - print(f" {file['name']} - {info['size']} bytes") - print() - - # Clean up - print("Cleaning up...") - client.rm(batch_dir, recursive=True) - print("Done!") - print() - - -def main(): - # Initialize client - client = AGFSClient("http://localhost:8080") - - try: - # Check connection - health = client.health() - print(f"Connected to AGFS server (version: {health.get('version', 'unknown')})") - print() - - # Run examples - mount_example(client) - grep_example(client) - streaming_example(client) - batch_operations(client) - - except AGFSClientError as e: - print(f"Error: {e}") - - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-sdk/python/examples/basic_usage.py b/third_party/agfs/agfs-sdk/python/examples/basic_usage.py deleted file mode 100644 index e64f34bb7..000000000 --- a/third_party/agfs/agfs-sdk/python/examples/basic_usage.py +++ /dev/null @@ -1,74 +0,0 @@ -"""Basic usage examples for pyagfs""" - -from pyagfs import AGFSClient, AGFSClientError - - -def main(): - # Initialize client - client = AGFSClient("http://localhost:8080") - - try: - # Check server health - print("Checking server health...") - health = client.health() - print(f"Server version: {health.get('version', 'unknown')}") - print() - - # List directory contents - print("Listing root directory:") - files = client.ls("/") - for file in files: - file_type = "DIR " if file["isDir"] else "FILE" - print(f" [{file_type}] {file['name']}") - print() - - # Create a test directory - test_dir = "/test_pyagfs" - print(f"Creating directory: {test_dir}") - client.mkdir(test_dir) - print() - - # Create and write to a file - test_file = f"{test_dir}/hello.txt" - content = b"Hello from pyagfs SDK!" - print(f"Writing to file: {test_file}") - client.write(test_file, content) - print() - - # Read the file back - print(f"Reading file: {test_file}") - read_content = client.cat(test_file) - print(f"Content: {read_content.decode()}") - print() - - # Get file information - print(f"Getting file info: {test_file}") - info = client.stat(test_file) - print(f" Size: {info.get('size')} bytes") - print(f" Mode: {info.get('mode')}") - print() - - # List the test directory - print(f"Listing {test_dir}:") - files = client.ls(test_dir) - for file in files: - print(f" - {file['name']}") - print() - - # Rename the file - new_file = f"{test_dir}/renamed.txt" - print(f"Renaming {test_file} to {new_file}") - client.mv(test_file, new_file) - print() - - # Clean up - print(f"Removing directory: {test_dir}") - client.rm(test_dir, recursive=True) - print("Done!") - - except AGFSClientError as e: - print(f"Error: {e}") - - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-sdk/python/examples/helpers_usage.py b/third_party/agfs/agfs-sdk/python/examples/helpers_usage.py deleted file mode 100644 index 4d959b40d..000000000 --- a/third_party/agfs/agfs-sdk/python/examples/helpers_usage.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Helper functions usage examples for pyagfs""" - -from pyagfs import AGFSClient, AGFSClientError, cp, upload, download -import tempfile -import os - - -def main(): - # Initialize client - client = AGFSClient("http://localhost:8080") - - try: - print("=== AGFS Helper Functions Examples ===\n") - - # Setup: Create test directory and files - test_dir = "/local/test" - print(f"Setting up test directory: {test_dir}") - try: - client.mkdir(test_dir) - except AGFSClientError: - # Directory might already exist - pass - - # Create some test files - print("Creating test files...") - client.write(f"{test_dir}/file1.txt", b"This is file 1") - client.write(f"{test_dir}/file2.txt", b"This is file 2") - - # Create a subdirectory with files - client.mkdir(f"{test_dir}/subdir") - client.write(f"{test_dir}/subdir/file3.txt", b"This is file 3 in subdir") - client.write(f"{test_dir}/subdir/file4.txt", b"This is file 4 in subdir") - print() - - # Example 1: Copy a single file within AGFS - print("1. Copy single file within AGFS:") - print(f" cp(client, '{test_dir}/file1.txt', '{test_dir}/file1_copy.txt')") - cp(client, f"{test_dir}/file1.txt", f"{test_dir}/file1_copy.txt") - print(" ✓ File copied successfully") - - # Verify - content = client.cat(f"{test_dir}/file1_copy.txt") - print(f" Content: {content.decode()}") - print() - - # Example 2: Copy a directory recursively within AGFS - print("2. Copy directory recursively within AGFS:") - print(f" cp(client, '{test_dir}/subdir', '{test_dir}/subdir_copy', recursive=True)") - cp(client, f"{test_dir}/subdir", f"{test_dir}/subdir_copy", recursive=True) - print(" ✓ Directory copied successfully") - - # Verify - files = client.ls(f"{test_dir}/subdir_copy") - print(f" Files in copied directory: {[f['name'] for f in files]}") - print() - - # Example 3: Upload a file from local filesystem to AGFS - print("3. Upload file from local filesystem:") - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt') as f: - local_file = f.name - f.write("This is a local file to upload") - - print(f" upload(client, '{local_file}', '{test_dir}/uploaded.txt')") - upload(client, local_file, f"{test_dir}/uploaded.txt") - print(" ✓ File uploaded successfully") - - # Verify - content = client.cat(f"{test_dir}/uploaded.txt") - print(f" Content: {content.decode()}") - - # Clean up temp file - os.unlink(local_file) - print() - - # Example 4: Upload a directory from local filesystem to AGFS - print("4. Upload directory from local filesystem:") - with tempfile.TemporaryDirectory() as tmpdir: - # Create local directory structure - os.makedirs(os.path.join(tmpdir, "local_dir")) - with open(os.path.join(tmpdir, "local_dir", "local1.txt"), 'w') as f: - f.write("Local file 1") - with open(os.path.join(tmpdir, "local_dir", "local2.txt"), 'w') as f: - f.write("Local file 2") - - local_dir = os.path.join(tmpdir, "local_dir") - print(f" upload(client, '{local_dir}', '{test_dir}/uploaded_dir', recursive=True)") - upload(client, local_dir, f"{test_dir}/uploaded_dir", recursive=True) - print(" ✓ Directory uploaded successfully") - - # Verify - files = client.ls(f"{test_dir}/uploaded_dir") - print(f" Files in uploaded directory: {[f['name'] for f in files]}") - print() - - # Example 5: Download a file from AGFS to local filesystem - print("5. Download file from AGFS to local filesystem:") - with tempfile.TemporaryDirectory() as tmpdir: - local_download = os.path.join(tmpdir, "downloaded.txt") - print(f" download(client, '{test_dir}/file2.txt', '{local_download}')") - download(client, f"{test_dir}/file2.txt", local_download) - print(" ✓ File downloaded successfully") - - # Verify - with open(local_download, 'r') as f: - content = f.read() - print(f" Content: {content}") - print() - - # Example 6: Download a directory from AGFS to local filesystem - print("6. Download directory from AGFS to local filesystem:") - with tempfile.TemporaryDirectory() as tmpdir: - local_dir_download = os.path.join(tmpdir, "downloaded_dir") - print(f" download(client, '{test_dir}/subdir', '{local_dir_download}', recursive=True)") - download(client, f"{test_dir}/subdir", local_dir_download, recursive=True) - print(" ✓ Directory downloaded successfully") - - # Verify - files = os.listdir(local_dir_download) - print(f" Files in downloaded directory: {files}") - - # Read one file to verify content - with open(os.path.join(local_dir_download, "file3.txt"), 'r') as f: - content = f.read() - print(f" Content of file3.txt: {content}") - print() - - # Example 7: Use streaming for large files - print("7. Copy large file with streaming:") - # Create a larger test file - large_content = b"Large file content\n" * 1000 # ~19KB - client.write(f"{test_dir}/large_file.txt", large_content) - - print(f" cp(client, '{test_dir}/large_file.txt', '{test_dir}/large_copy.txt', stream=True)") - cp(client, f"{test_dir}/large_file.txt", f"{test_dir}/large_copy.txt", stream=True) - print(" ✓ Large file copied with streaming") - - # Verify size - info = client.stat(f"{test_dir}/large_copy.txt") - print(f" Size: {info.get('size')} bytes") - print() - - # Clean up - print("Cleaning up test directory...") - client.rm(test_dir, recursive=True) - print("✓ Done!\n") - - print("=== All Examples Completed Successfully ===") - - except AGFSClientError as e: - print(f"Error: {e}") - except Exception as e: - print(f"Unexpected error: {e}") - # Try to clean up on error - try: - client.rm(test_dir, recursive=True) - except: - pass - - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-sdk/python/pyagfs/__init__.py b/third_party/agfs/agfs-sdk/python/pyagfs/__init__.py deleted file mode 100644 index 848a40521..000000000 --- a/third_party/agfs/agfs-sdk/python/pyagfs/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""AGFS Python SDK - Client library for AGFS Server API""" - -__version__ = "0.1.7" - -from .client import AGFSClient, FileHandle -from .exceptions import ( - AGFSClientError, - AGFSConnectionError, - AGFSTimeoutError, - AGFSHTTPError, - AGFSNotSupportedError, -) -from .helpers import cp, upload, download - -# Binding client depends on a native shared library (libagfsbinding.so/dylib/dll). -# Make it optional so the pure-HTTP AGFSClient remains usable when the native -# library is not installed (e.g. Docker images without CGO build). -try: - from .binding_client import AGFSBindingClient, FileHandle as BindingFileHandle -except (ImportError, OSError): - AGFSBindingClient = None - BindingFileHandle = None - -__all__ = [ - "AGFSClient", - "AGFSBindingClient", - "FileHandle", - "BindingFileHandle", - "AGFSClientError", - "AGFSConnectionError", - "AGFSTimeoutError", - "AGFSHTTPError", - "AGFSNotSupportedError", - "cp", - "upload", - "download", -] diff --git a/third_party/agfs/agfs-sdk/python/pyagfs/binding_client.py b/third_party/agfs/agfs-sdk/python/pyagfs/binding_client.py deleted file mode 100644 index 1287b9e91..000000000 --- a/third_party/agfs/agfs-sdk/python/pyagfs/binding_client.py +++ /dev/null @@ -1,604 +0,0 @@ -"""AGFS Python Binding Client - Direct binding to AGFS Server implementation""" - -import ctypes -import json -import os -import platform -from pathlib import Path -from typing import List, Dict, Any, Optional, Union, Iterator, BinaryIO - -from .exceptions import AGFSClientError, AGFSNotSupportedError - - -def _find_library() -> str: - """Find the AGFS binding shared library.""" - system = platform.system() - - if system == "Darwin": - lib_name = "libagfsbinding.dylib" - elif system == "Linux": - lib_name = "libagfsbinding.so" - elif system == "Windows": - lib_name = "libagfsbinding.dll" - else: - raise AGFSClientError(f"Unsupported platform: {system}") - - search_paths = [ - Path(__file__).parent / "lib" / lib_name, - Path(__file__).parent.parent / "lib" / lib_name, - Path(__file__).parent.parent.parent / "lib" / lib_name, - Path("/usr/local/lib") / lib_name, - Path("/usr/lib") / lib_name, - Path(os.environ.get("AGFS_LIB_PATH", "")) / lib_name - if os.environ.get("AGFS_LIB_PATH") - else None, - ] - - for path in search_paths: - if path and path.exists(): - return str(path) - - raise AGFSClientError( - f"Could not find {lib_name}. Please set AGFS_LIB_PATH environment variable " - f"or install the library to /usr/local/lib" - ) - - -class BindingLib: - """Wrapper for the AGFS binding shared library.""" - - _instance = None - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - cls._instance._load_library() - return cls._instance - - def _load_library(self): - lib_path = _find_library() - self.lib = ctypes.CDLL(lib_path) - self._setup_functions() - - def _setup_functions(self): - self.lib.AGFS_NewClient.argtypes = [] - self.lib.AGFS_NewClient.restype = ctypes.c_int64 - - self.lib.AGFS_FreeClient.argtypes = [ctypes.c_int64] - self.lib.AGFS_FreeClient.restype = None - - self.lib.AGFS_GetLastError.argtypes = [ctypes.c_int64] - self.lib.AGFS_GetLastError.restype = ctypes.c_char_p - - self.lib.AGFS_FreeString.argtypes = [ctypes.c_char_p] - self.lib.AGFS_FreeString.restype = None - - self.lib.AGFS_Health.argtypes = [ctypes.c_int64] - self.lib.AGFS_Health.restype = ctypes.c_int - - self.lib.AGFS_GetCapabilities.argtypes = [ctypes.c_int64] - self.lib.AGFS_GetCapabilities.restype = ctypes.c_char_p - - self.lib.AGFS_Ls.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Ls.restype = ctypes.c_char_p - - self.lib.AGFS_Read.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_int64, - ctypes.c_int64, - ctypes.POINTER(ctypes.c_char_p), - ctypes.POINTER(ctypes.c_int64), - ] - self.lib.AGFS_Read.restype = ctypes.c_int64 - - self.lib.AGFS_Write.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_void_p, - ctypes.c_int64, - ] - self.lib.AGFS_Write.restype = ctypes.c_char_p - - self.lib.AGFS_Create.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Create.restype = ctypes.c_char_p - - self.lib.AGFS_Mkdir.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_uint] - self.lib.AGFS_Mkdir.restype = ctypes.c_char_p - - self.lib.AGFS_Rm.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_int] - self.lib.AGFS_Rm.restype = ctypes.c_char_p - - self.lib.AGFS_Stat.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Stat.restype = ctypes.c_char_p - - self.lib.AGFS_Mv.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_char_p] - self.lib.AGFS_Mv.restype = ctypes.c_char_p - - self.lib.AGFS_Chmod.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.c_uint] - self.lib.AGFS_Chmod.restype = ctypes.c_char_p - - self.lib.AGFS_Touch.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Touch.restype = ctypes.c_char_p - - self.lib.AGFS_Mounts.argtypes = [ctypes.c_int64] - self.lib.AGFS_Mounts.restype = ctypes.c_char_p - - self.lib.AGFS_Mount.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.c_char_p, - ] - self.lib.AGFS_Mount.restype = ctypes.c_char_p - - self.lib.AGFS_Unmount.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_Unmount.restype = ctypes.c_char_p - - self.lib.AGFS_LoadPlugin.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_LoadPlugin.restype = ctypes.c_char_p - - self.lib.AGFS_UnloadPlugin.argtypes = [ctypes.c_int64, ctypes.c_char_p] - self.lib.AGFS_UnloadPlugin.restype = ctypes.c_char_p - - self.lib.AGFS_ListPlugins.argtypes = [ctypes.c_int64] - self.lib.AGFS_ListPlugins.restype = ctypes.c_char_p - - self.lib.AGFS_OpenHandle.argtypes = [ - ctypes.c_int64, - ctypes.c_char_p, - ctypes.c_int, - ctypes.c_uint, - ctypes.c_int, - ] - self.lib.AGFS_OpenHandle.restype = ctypes.c_int64 - - self.lib.AGFS_CloseHandle.argtypes = [ctypes.c_int64] - self.lib.AGFS_CloseHandle.restype = ctypes.c_char_p - - self.lib.AGFS_HandleRead.argtypes = [ - ctypes.c_int64, - ctypes.c_int64, - ctypes.c_int64, - ctypes.c_int, - ] - self.lib.AGFS_HandleRead.restype = ctypes.c_char_p - - self.lib.AGFS_HandleWrite.argtypes = [ - ctypes.c_int64, - ctypes.c_void_p, - ctypes.c_int64, - ctypes.c_int64, - ctypes.c_int, - ] - self.lib.AGFS_HandleWrite.restype = ctypes.c_char_p - - self.lib.AGFS_HandleSeek.argtypes = [ctypes.c_int64, ctypes.c_int64, ctypes.c_int] - self.lib.AGFS_HandleSeek.restype = ctypes.c_char_p - - self.lib.AGFS_HandleSync.argtypes = [ctypes.c_int64] - self.lib.AGFS_HandleSync.restype = ctypes.c_char_p - - self.lib.AGFS_HandleStat.argtypes = [ctypes.c_int64] - self.lib.AGFS_HandleStat.restype = ctypes.c_char_p - - self.lib.AGFS_ListHandles.argtypes = [ctypes.c_int64] - self.lib.AGFS_ListHandles.restype = ctypes.c_char_p - - self.lib.AGFS_GetHandleInfo.argtypes = [ctypes.c_int64] - self.lib.AGFS_GetHandleInfo.restype = ctypes.c_char_p - - -class AGFSBindingClient: - """Client for interacting with AGFS using Python binding (no HTTP server required). - - This client directly uses the AGFS server implementation through a shared library, - providing better performance than the HTTP client by avoiding network overhead. - - The interface is compatible with the HTTP client (AGFSClient), allowing easy - switching between implementations. - """ - - def __init__(self, config_path: Optional[str] = None): - """ - Initialize AGFS binding client. - - Args: - config_path: Optional path to configuration file (not used in binding mode). - """ - self._lib = BindingLib() - self._client_id = self._lib.lib.AGFS_NewClient() - if self._client_id <= 0: - raise AGFSClientError("Failed to create AGFS client") - - def __del__(self): - if hasattr(self, "_client_id") and self._client_id > 0: - try: - self._lib.lib.AGFS_FreeClient(self._client_id) - except Exception: - pass - - def _parse_response(self, result: bytes) -> Dict[str, Any]: - """Parse JSON response from the library.""" - if isinstance(result, bytes): - result = result.decode("utf-8") - data = json.loads(result) - - if "error_id" in data and data["error_id"] != 0: - error_msg = self._lib.lib.AGFS_GetLastError(data["error_id"]) - if isinstance(error_msg, bytes): - error_msg = error_msg.decode("utf-8") - raise AGFSClientError(error_msg if error_msg else "Unknown error") - - return data - - def health(self) -> Dict[str, Any]: - """Check client health.""" - result = self._lib.lib.AGFS_Health(self._client_id) - return {"status": "healthy" if result == 1 else "unhealthy"} - - def get_capabilities(self) -> Dict[str, Any]: - """Get client capabilities.""" - result = self._lib.lib.AGFS_GetCapabilities(self._client_id) - return self._parse_response(result) - - def ls(self, path: str = "/") -> List[Dict[str, Any]]: - """List directory contents.""" - result = self._lib.lib.AGFS_Ls(self._client_id, path.encode("utf-8")) - data = self._parse_response(result) - return data.get("files", []) - - def read(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): - return self.cat(path, offset, size, stream) - - def cat(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): - """Read file content with optional offset and size.""" - if stream: - raise AGFSNotSupportedError("Streaming not supported in binding mode") - - result_ptr = ctypes.c_char_p() - size_ptr = ctypes.c_int64() - - error_id = self._lib.lib.AGFS_Read( - self._client_id, - path.encode("utf-8"), - ctypes.c_int64(offset), - ctypes.c_int64(size), - ctypes.byref(result_ptr), - ctypes.byref(size_ptr), - ) - - if error_id < 0: - error_msg = self._lib.lib.AGFS_GetLastError(error_id) - if isinstance(error_msg, bytes): - error_msg = error_msg.decode("utf-8") - raise AGFSClientError(error_msg if error_msg else "Unknown error") - - if result_ptr: - data = ctypes.string_at(result_ptr, size_ptr.value) - return data - - return b"" - - def write( - self, path: str, data: Union[bytes, Iterator[bytes], BinaryIO], max_retries: int = 3 - ) -> str: - """Write data to file.""" - if not isinstance(data, bytes): - if hasattr(data, "read"): - data = data.read() - else: - data = b"".join(data) - - result = self._lib.lib.AGFS_Write( - self._client_id, path.encode("utf-8"), data, ctypes.c_int64(len(data)) - ) - resp = self._parse_response(result) - return resp.get("message", "OK") - - def create(self, path: str) -> Dict[str, Any]: - """Create a new file.""" - result = self._lib.lib.AGFS_Create(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def mkdir(self, path: str, mode: str = "755") -> Dict[str, Any]: - """Create a directory.""" - mode_int = int(mode, 8) - result = self._lib.lib.AGFS_Mkdir( - self._client_id, path.encode("utf-8"), ctypes.c_uint(mode_int) - ) - return self._parse_response(result) - - def rm(self, path: str, recursive: bool = False) -> Dict[str, Any]: - """Remove a file or directory.""" - result = self._lib.lib.AGFS_Rm(self._client_id, path.encode("utf-8"), 1 if recursive else 0) - return self._parse_response(result) - - def stat(self, path: str) -> Dict[str, Any]: - """Get file/directory information.""" - result = self._lib.lib.AGFS_Stat(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def mv(self, old_path: str, new_path: str) -> Dict[str, Any]: - """Rename/move a file or directory.""" - result = self._lib.lib.AGFS_Mv( - self._client_id, old_path.encode("utf-8"), new_path.encode("utf-8") - ) - return self._parse_response(result) - - def chmod(self, path: str, mode: int) -> Dict[str, Any]: - """Change file permissions.""" - result = self._lib.lib.AGFS_Chmod( - self._client_id, path.encode("utf-8"), ctypes.c_uint(mode) - ) - return self._parse_response(result) - - def touch(self, path: str) -> Dict[str, Any]: - """Touch a file.""" - result = self._lib.lib.AGFS_Touch(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def mounts(self) -> List[Dict[str, Any]]: - """List all mounted plugins.""" - result = self._lib.lib.AGFS_Mounts(self._client_id) - data = self._parse_response(result) - return data.get("mounts", []) - - def mount(self, fstype: str, path: str, config: Dict[str, Any]) -> Dict[str, Any]: - """Mount a plugin dynamically.""" - config_json = json.dumps(config) - result = self._lib.lib.AGFS_Mount( - self._client_id, - fstype.encode("utf-8"), - path.encode("utf-8"), - config_json.encode("utf-8"), - ) - return self._parse_response(result) - - def unmount(self, path: str) -> Dict[str, Any]: - """Unmount a plugin.""" - result = self._lib.lib.AGFS_Unmount(self._client_id, path.encode("utf-8")) - return self._parse_response(result) - - def load_plugin(self, library_path: str) -> Dict[str, Any]: - """Load an external plugin.""" - result = self._lib.lib.AGFS_LoadPlugin(self._client_id, library_path.encode("utf-8")) - return self._parse_response(result) - - def unload_plugin(self, library_path: str) -> Dict[str, Any]: - """Unload an external plugin.""" - result = self._lib.lib.AGFS_UnloadPlugin(self._client_id, library_path.encode("utf-8")) - return self._parse_response(result) - - def list_plugins(self) -> List[str]: - """List all loaded external plugins.""" - result = self._lib.lib.AGFS_ListPlugins(self._client_id) - data = self._parse_response(result) - return data.get("loaded_plugins", []) - - def get_plugins_info(self) -> List[dict]: - """Get detailed information about all loaded plugins.""" - return self.list_plugins() - - def grep( - self, - path: str, - pattern: str, - recursive: bool = False, - case_insensitive: bool = False, - stream: bool = False, - node_limit: Optional[int] = None, - ): - """Search for a pattern in files.""" - raise AGFSNotSupportedError("Grep not supported in binding mode") - - def digest(self, path: str, algorithm: str = "xxh3") -> Dict[str, Any]: - """Calculate the digest of a file.""" - raise AGFSNotSupportedError("Digest not supported in binding mode") - - def open_handle( - self, path: str, flags: int = 0, mode: int = 0o644, lease: int = 60 - ) -> "FileHandle": - """Open a file handle for stateful operations.""" - handle_id = self._lib.lib.AGFS_OpenHandle( - self._client_id, path.encode("utf-8"), flags, ctypes.c_uint(mode), lease - ) - - if handle_id < 0: - raise AGFSClientError("Failed to open handle") - - return FileHandle(self, handle_id, path, flags) - - def list_handles(self) -> List[Dict[str, Any]]: - """List all active file handles.""" - result = self._lib.lib.AGFS_ListHandles(self._client_id) - data = self._parse_response(result) - return data.get("handles", []) - - def get_handle_info(self, handle_id: int) -> Dict[str, Any]: - """Get information about a specific handle.""" - result = self._lib.lib.AGFS_GetHandleInfo(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def close_handle(self, handle_id: int) -> Dict[str, Any]: - """Close a file handle.""" - result = self._lib.lib.AGFS_CloseHandle(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def handle_read(self, handle_id: int, size: int = -1, offset: Optional[int] = None) -> bytes: - """Read from a file handle.""" - has_offset = 1 if offset is not None else 0 - offset_val = offset if offset is not None else 0 - - result = self._lib.lib.AGFS_HandleRead( - ctypes.c_int64(handle_id), ctypes.c_int64(size), ctypes.c_int64(offset_val), has_offset - ) - - if isinstance(result, bytes): - return result - - data = json.loads(result.decode("utf-8") if isinstance(result, bytes) else result) - if "error_id" in data and data["error_id"] != 0: - error_msg = self._lib.lib.AGFS_GetLastError(data["error_id"]) - if isinstance(error_msg, bytes): - error_msg = error_msg.decode("utf-8") - raise AGFSClientError(error_msg if error_msg else "Unknown error") - - return result if isinstance(result, bytes) else result.encode("utf-8") - - def handle_write(self, handle_id: int, data: bytes, offset: Optional[int] = None) -> int: - """Write to a file handle.""" - has_offset = 1 if offset is not None else 0 - offset_val = offset if offset is not None else 0 - - result = self._lib.lib.AGFS_HandleWrite( - ctypes.c_int64(handle_id), - data, - ctypes.c_int64(len(data)), - ctypes.c_int64(offset_val), - has_offset, - ) - resp = self._parse_response(result) - return resp.get("bytes_written", 0) - - def handle_seek(self, handle_id: int, offset: int, whence: int = 0) -> int: - """Seek within a file handle.""" - result = self._lib.lib.AGFS_HandleSeek( - ctypes.c_int64(handle_id), ctypes.c_int64(offset), whence - ) - data = self._parse_response(result) - return data.get("position", 0) - - def handle_sync(self, handle_id: int) -> Dict[str, Any]: - """Sync a file handle.""" - result = self._lib.lib.AGFS_HandleSync(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def handle_stat(self, handle_id: int) -> Dict[str, Any]: - """Get file info via handle.""" - result = self._lib.lib.AGFS_HandleStat(ctypes.c_int64(handle_id)) - return self._parse_response(result) - - def renew_handle(self, handle_id: int, lease: int = 60) -> Dict[str, Any]: - """Renew the lease on a file handle.""" - return {"message": "lease renewed", "lease": lease} - - -class FileHandle: - """A file handle for stateful file operations. - - Supports context manager protocol for automatic cleanup. - """ - - O_RDONLY = 0 - O_WRONLY = 1 - O_RDWR = 2 - O_APPEND = 8 - O_CREATE = 16 - O_EXCL = 32 - O_TRUNC = 64 - - SEEK_SET = 0 - SEEK_CUR = 1 - SEEK_END = 2 - - def __init__(self, client: AGFSBindingClient, handle_id: int, path: str, flags: int): - self._client = client - self._handle_id = handle_id - self._path = path - self._flags = flags - self._closed = False - - @property - def handle_id(self) -> int: - """The handle ID.""" - return self._handle_id - - @property - def path(self) -> str: - """The file path.""" - return self._path - - @property - def flags(self) -> int: - """The open flags (numeric).""" - return self._flags - - @property - def closed(self) -> bool: - """Whether the handle is closed.""" - return self._closed - - def read(self, size: int = -1) -> bytes: - """Read from current position.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_read(self._handle_id, size) - - def read_at(self, size: int, offset: int) -> bytes: - """Read at specific offset (pread).""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_read(self._handle_id, size, offset) - - def write(self, data: bytes) -> int: - """Write at current position.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_write(self._handle_id, data) - - def write_at(self, data: bytes, offset: int) -> int: - """Write at specific offset (pwrite).""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_write(self._handle_id, data, offset) - - def seek(self, offset: int, whence: int = 0) -> int: - """Seek to position.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_seek(self._handle_id, offset, whence) - - def tell(self) -> int: - """Get current position.""" - return self.seek(0, self.SEEK_CUR) - - def sync(self) -> None: - """Flush data to storage.""" - if self._closed: - raise AGFSClientError("Handle is closed") - self._client.handle_sync(self._handle_id) - - def stat(self) -> Dict[str, Any]: - """Get file info.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_stat(self._handle_id) - - def info(self) -> Dict[str, Any]: - """Get handle info.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.get_handle_info(self._handle_id) - - def renew(self, lease: int = 60) -> Dict[str, Any]: - """Renew the handle lease.""" - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.renew_handle(self._handle_id, lease) - - def close(self) -> None: - """Close the handle.""" - if not self._closed: - self._client.close_handle(self._handle_id) - self._closed = True - - def __enter__(self) -> "FileHandle": - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self.close() - - def __repr__(self) -> str: - status = "closed" if self._closed else "open" - return f"FileHandle(id={self._handle_id}, path={self._path}, flags={self._flags}, {status})" diff --git a/third_party/agfs/agfs-sdk/python/pyagfs/client.py b/third_party/agfs/agfs-sdk/python/pyagfs/client.py deleted file mode 100644 index f532333a9..000000000 --- a/third_party/agfs/agfs-sdk/python/pyagfs/client.py +++ /dev/null @@ -1,1003 +0,0 @@ -"""AGFS Server API Client""" - -import requests -import time -from typing import List, Dict, Any, Optional, Union, Iterator, BinaryIO -from requests.exceptions import ConnectionError, Timeout, RequestException - -from .exceptions import AGFSClientError, AGFSHTTPError, AGFSNotSupportedError - - -class AGFSClient: - """Client for interacting with AGFS (Plugin-based File System) Server API""" - - def __init__(self, api_base_url="http://localhost:8080", timeout=10): - """ - Initialize AGFS client. - - Args: - api_base_url: API base URL. Can be either full URL with "/api/v1" or just the base. - If "/api/v1" is not present, it will be automatically appended. - e.g., "http://localhost:8080" or "http://localhost:8080/api/v1" - timeout: Request timeout in seconds (default: 10) - """ - api_base_url = api_base_url.rstrip("/") - # Auto-append /api/v1 if not present - if not api_base_url.endswith("/api/v1"): - api_base_url = api_base_url + "/api/v1" - self.api_base = api_base_url - self.session = requests.Session() - self.timeout = timeout - - def _handle_request_error(self, e: Exception, operation: str = "request") -> None: - """Convert request exceptions to user-friendly error messages""" - if isinstance(e, ConnectionError): - # Extract host and port from the error message - url_parts = self.api_base.split("://") - if len(url_parts) > 1: - host_port = url_parts[1].split("/")[0] - else: - host_port = "server" - raise AGFSClientError(f"Connection refused - server not running at {host_port}") - elif isinstance(e, Timeout): - raise AGFSClientError(f"Request timeout after {self.timeout}s") - elif isinstance(e, requests.exceptions.HTTPError): - # Extract useful error information from response - if hasattr(e, "response") and e.response is not None: - status_code = e.response.status_code - - # Special handling for 501 Not Implemented - always raise typed error - if status_code == 501: - try: - error_data = e.response.json() - error_msg = error_data.get("error", "Operation not supported") - except (ValueError, KeyError, TypeError): - error_msg = "Operation not supported" - raise AGFSNotSupportedError(error_msg) - - # Try to get error message from JSON response first - error_msg = None - try: - error_data = e.response.json() - error_msg = error_data.get("error", "") - except (ValueError, KeyError, TypeError): - pass - - # Always use AGFSHTTPError to preserve status_code - if error_msg: - raise AGFSHTTPError(error_msg, status_code) - elif status_code == 404: - raise AGFSHTTPError("No such file or directory", status_code) - elif status_code == 403: - raise AGFSHTTPError("Permission denied", status_code) - elif status_code == 409: - raise AGFSHTTPError("Resource already exists", status_code) - elif status_code == 500: - raise AGFSHTTPError("Internal server error", status_code) - elif status_code == 502: - raise AGFSHTTPError("Bad Gateway - backend service unavailable", status_code) - else: - raise AGFSHTTPError(f"HTTP error {status_code}", status_code) - else: - raise AGFSHTTPError("HTTP error", None) - else: - # For other exceptions, re-raise with simplified message - raise AGFSClientError(str(e)) - - def health(self) -> Dict[str, Any]: - """Check server health""" - response = self.session.get(f"{self.api_base}/health", timeout=self.timeout) - response.raise_for_status() - return response.json() - - def get_capabilities(self) -> Dict[str, Any]: - """Get server capabilities - - Returns: - Dict containing 'version' and 'features' list. - e.g., {'version': '1.4.0', 'features': ['handlefs', 'grep', ...]} - """ - try: - response = self.session.get(f"{self.api_base}/capabilities", timeout=self.timeout) - - # If capabilities endpoint doesn't exist (older server), return empty capabilities - if response.status_code == 404: - return {"version": "unknown", "features": []} - - response.raise_for_status() - return response.json() - except Exception as e: - # If capabilities check fails, treat it as unknown/empty rather than error - # unless it's a connection error - if isinstance(e, ConnectionError): - self._handle_request_error(e) - return {"version": "unknown", "features": []} - - def ls(self, path: str = "/") -> List[Dict[str, Any]]: - """List directory contents""" - try: - response = self.session.get( - f"{self.api_base}/directories", params={"path": path}, timeout=self.timeout - ) - response.raise_for_status() - data = response.json() - files = data.get("files") - return files if files is not None else [] - except Exception as e: - self._handle_request_error(e) - - def read(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): - return self.cat(path, offset, size, stream) - - def cat(self, path: str, offset: int = 0, size: int = -1, stream: bool = False): - """Read file content with optional offset and size - - Args: - path: File path - offset: Starting position (default: 0) - size: Number of bytes to read (default: -1, read all) - stream: Enable streaming mode for continuous reads (default: False) - - Returns: - If stream=False: bytes content - If stream=True: Response object for iteration - """ - try: - params = {"path": path} - - if stream: - params["stream"] = "true" - # Streaming mode - return response object for iteration - response = self.session.get( - f"{self.api_base}/files", - params=params, - stream=True, - timeout=None, # No timeout for streaming - ) - response.raise_for_status() - return response - else: - # Normal mode - return content - if offset > 0: - params["offset"] = str(offset) - if size >= 0: - params["size"] = str(size) - - response = self.session.get( - f"{self.api_base}/files", params=params, timeout=self.timeout - ) - response.raise_for_status() - return response.content - except Exception as e: - self._handle_request_error(e) - - def write( - self, path: str, data: Union[bytes, Iterator[bytes], BinaryIO], max_retries: int = 3 - ) -> str: - """Write data to file and return the response message - - Args: - path: Path to write the file - data: File content as bytes, iterator of bytes, or file-like object - max_retries: Maximum number of retry attempts (default: 3) - - Returns: - Response message from server - """ - # Calculate timeout based on file size (if known) - # For streaming data, use a larger default timeout - if isinstance(data, bytes): - data_size_mb = len(data) / (1024 * 1024) - write_timeout = max(10, min(300, int(data_size_mb * 1 + 10))) - else: - # For streaming/unknown size, use no timeout - write_timeout = None - - last_error = None - - for attempt in range(max_retries + 1): - try: - response = self.session.put( - f"{self.api_base}/files", - params={"path": path}, - data=data, # requests supports bytes, iterator, or file-like object - timeout=write_timeout, - ) - response.raise_for_status() - result = response.json() - - # If we succeeded after retrying, let user know - if attempt > 0: - print(f"✓ Upload succeeded after {attempt} retry(ies)") - - return result.get("message", "OK") - - except (ConnectionError, Timeout) as e: - # Network errors and timeouts are retryable - last_error = e - - if attempt < max_retries: - # Exponential backoff: 1s, 2s, 4s - wait_time = 2**attempt - print( - f"⚠ Upload failed (attempt {attempt + 1}/{max_retries + 1}): {type(e).__name__}" - ) - print(f" Retrying in {wait_time} seconds...") - time.sleep(wait_time) - else: - # Last attempt failed - print(f"✗ Upload failed after {max_retries + 1} attempts") - self._handle_request_error(e) - - except requests.exceptions.HTTPError as e: - # Check if it's a server error (5xx) which might be retryable - if hasattr(e, "response") and e.response is not None: - status_code = e.response.status_code - - # Only retry specific server errors that indicate temporary issues - # 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout - # Do NOT retry 500 Internal Server Error (usually indicates business logic errors) - retryable_5xx = [502, 503, 504] - - if status_code in retryable_5xx: - last_error = e - - if attempt < max_retries: - wait_time = 2**attempt - print( - f"⚠ Server error {status_code} (attempt {attempt + 1}/{max_retries + 1})" - ) - print(f" Retrying in {wait_time} seconds...") - time.sleep(wait_time) - else: - print(f"✗ Upload failed after {max_retries + 1} attempts") - self._handle_request_error(e) - else: - # 500 and other errors (including 4xx) are not retryable - # They usually indicate business logic errors or client mistakes - self._handle_request_error(e) - else: - self._handle_request_error(e) - - except Exception as e: - # Other exceptions are not retryable - self._handle_request_error(e) - - # Should not reach here, but just in case - if last_error: - self._handle_request_error(last_error) - - def create(self, path: str) -> Dict[str, Any]: - """Create a new file""" - try: - response = self.session.post( - f"{self.api_base}/files", params={"path": path}, timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def mkdir(self, path: str, mode: str = "755") -> Dict[str, Any]: - """Create a directory""" - try: - response = self.session.post( - f"{self.api_base}/directories", - params={"path": path, "mode": mode}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def rm(self, path: str, recursive: bool = False, force: bool = True) -> Dict[str, Any]: - """Remove a file or directory. - - Args: - path: Path to remove. - recursive: Remove directories recursively. - force: If True (default), ignore nonexistent files (like rm -f). Idempotent by default. - """ - try: - params = {"path": path} - if recursive: - params["recursive"] = "true" - response = self.session.delete( - f"{self.api_base}/files", - params=params, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as e: - if force and e.response is not None and e.response.status_code == 404: - return {"message": "deleted"} - self._handle_request_error(e) - except Exception as e: - self._handle_request_error(e) - - def stat(self, path: str) -> Dict[str, Any]: - """Get file/directory information""" - try: - response = self.session.get( - f"{self.api_base}/stat", params={"path": path}, timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def mv(self, old_path: str, new_path: str) -> Dict[str, Any]: - """Rename/move a file or directory""" - try: - response = self.session.post( - f"{self.api_base}/rename", - params={"path": old_path}, - json={"newPath": new_path}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def chmod(self, path: str, mode: int) -> Dict[str, Any]: - """Change file permissions""" - try: - response = self.session.post( - f"{self.api_base}/chmod", - params={"path": path}, - json={"mode": mode}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def touch(self, path: str) -> Dict[str, Any]: - """Touch a file (update timestamp by writing empty content)""" - try: - response = self.session.post( - f"{self.api_base}/touch", params={"path": path}, timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def mounts(self) -> List[Dict[str, Any]]: - """List all mounted plugins""" - try: - response = self.session.get(f"{self.api_base}/mounts", timeout=self.timeout) - response.raise_for_status() - data = response.json() - return data.get("mounts", []) - except Exception as e: - self._handle_request_error(e) - - def mount(self, fstype: str, path: str, config: Dict[str, Any]) -> Dict[str, Any]: - """Mount a plugin dynamically - - Args: - fstype: Filesystem type (e.g., 'sqlfs', 's3fs', 'memfs') - path: Mount path - config: Plugin configuration as dictionary - - Returns: - Response with message - """ - try: - response = self.session.post( - f"{self.api_base}/mount", - json={"fstype": fstype, "path": path, "config": config}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def unmount(self, path: str) -> Dict[str, Any]: - """Unmount a plugin""" - try: - response = self.session.post( - f"{self.api_base}/unmount", json={"path": path}, timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def load_plugin(self, library_path: str) -> Dict[str, Any]: - """Load an external plugin from a shared library or HTTP(S) URL - - Args: - library_path: Path to the shared library (.so/.dylib/.dll) or HTTP(S) URL - - Returns: - Response with message and plugin name - """ - try: - response = self.session.post( - f"{self.api_base}/plugins/load", - json={"library_path": library_path}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def unload_plugin(self, library_path: str) -> Dict[str, Any]: - """Unload an external plugin - - Args: - library_path: Path to the shared library - - Returns: - Response with message - """ - try: - response = self.session.post( - f"{self.api_base}/plugins/unload", - json={"library_path": library_path}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def list_plugins(self) -> List[str]: - """List all loaded external plugins - - Returns: - List of plugin library paths - """ - try: - response = self.session.get(f"{self.api_base}/plugins", timeout=self.timeout) - response.raise_for_status() - data = response.json() - - # Support both old and new API formats - if "loaded_plugins" in data: - # Old format - return data.get("loaded_plugins", []) - elif "plugins" in data: - # New format - extract library paths from external plugins only - plugins = data.get("plugins", []) - return [ - p.get("library_path", "") - for p in plugins - if p.get("is_external", False) and p.get("library_path") - ] - else: - return [] - except Exception as e: - self._handle_request_error(e) - - def get_plugins_info(self) -> List[dict]: - """Get detailed information about all loaded plugins - - Returns: - List of plugin info dictionaries with keys: - - name: Plugin name - - library_path: Path to plugin library (for external plugins) - - is_external: Whether this is an external plugin - - mounted_paths: List of mount point information - - config_params: List of configuration parameters (name, type, required, default, description) - """ - try: - response = self.session.get(f"{self.api_base}/plugins", timeout=self.timeout) - response.raise_for_status() - data = response.json() - return data.get("plugins", []) - except Exception as e: - self._handle_request_error(e) - - def grep( - self, - path: str, - pattern: str, - recursive: bool = False, - case_insensitive: bool = False, - stream: bool = False, - node_limit: Optional[int] = None, - ): - """Search for a pattern in files using regular expressions - - Args: - path: Path to file or directory to search - pattern: Regular expression pattern to search for - recursive: Whether to search recursively in directories (default: False) - case_insensitive: Whether to perform case-insensitive matching (default: False) - stream: Whether to stream results as NDJSON (default: False) - node_limit: Maximum number of results to return (default: None) - - Returns: - If stream=False: Dict with 'matches' (list of match objects) and 'count' - If stream=True: Iterator yielding match dicts and a final summary dict - - Example (non-stream): - >>> result = client.grep("/local/test-grep", "error", recursive=True) - >>> print(result['count']) - 2 - - Example (stream): - >>> for item in client.grep("/local/test-grep", "error", recursive=True, stream=True): - ... if item.get('type') == 'summary': - ... print(f"Total: {item['count']}") - ... else: - ... print(f"{item['file']}:{item['line']}: {item['content']}") - """ - try: - json_payload = { - "path": path, - "pattern": pattern, - "recursive": recursive, - "case_insensitive": case_insensitive, - "stream": stream, - } - if node_limit is not None: - json_payload["node_limit"] = node_limit - response = self.session.post( - f"{self.api_base}/grep", - json=json_payload, - timeout=None if stream else self.timeout, - stream=stream, - ) - response.raise_for_status() - - if stream: - # Return iterator for streaming results - return self._parse_ndjson_stream(response) - else: - # Return complete result - return response.json() - except Exception as e: - self._handle_request_error(e) - - def _parse_ndjson_stream(self, response): - """Parse NDJSON streaming response line by line""" - import json - - for line in response.iter_lines(): - if line: - try: - yield json.loads(line) - except json.JSONDecodeError as e: - # Skip malformed lines - continue - - def digest(self, path: str, algorithm: str = "xxh3") -> Dict[str, Any]: - """Calculate the digest of a file using specified algorithm - - Args: - path: Path to the file - algorithm: Hash algorithm to use - "xxh3" or "md5" (default: "xxh3") - - Returns: - Dict with 'algorithm', 'path', and 'digest' keys - - Example: - >>> result = client.digest("/local/file.txt", "xxh3") - >>> print(result['digest']) - abc123def456... - - >>> result = client.digest("/local/file.txt", "md5") - >>> print(result['digest']) - 5d41402abc4b2a76b9719d911017c592 - """ - try: - response = self.session.post( - f"{self.api_base}/digest", - json={"algorithm": algorithm, "path": path}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - # ==================== HandleFS API ==================== - # These APIs provide POSIX-like file handle operations for - # filesystems that support stateful file access (e.g., seek, pread/pwrite) - - def open_handle( - self, path: str, flags: int = 0, mode: int = 0o644, lease: int = 60 - ) -> "FileHandle": - """Open a file handle for stateful operations - - Args: - path: Path to the file - flags: Open flags (0=O_RDONLY, 1=O_WRONLY, 2=O_RDWR, can OR with O_APPEND=8, O_CREATE=16, O_EXCL=32, O_TRUNC=64) - mode: File mode for creation (default: 0644) - lease: Lease duration in seconds (default: 60) - - Returns: - FileHandle object for performing operations - - Example: - >>> with client.open_handle("/memfs/file.txt", flags=2) as fh: - ... data = fh.read(100) - ... fh.seek(0) - ... fh.write(b"Hello") - """ - try: - response = self.session.post( - f"{self.api_base}/handles/open", - params={"path": path, "flags": str(flags), "mode": str(mode), "lease": str(lease)}, - timeout=self.timeout, - ) - response.raise_for_status() - data = response.json() - return FileHandle(self, data["handle_id"], path, data.get("flags", "")) - except Exception as e: - self._handle_request_error(e) - - def list_handles(self) -> List[Dict[str, Any]]: - """List all active file handles - - Returns: - List of handle info dicts with keys: handle_id, path, flags, lease, expires_at, created_at, last_access - """ - try: - response = self.session.get(f"{self.api_base}/handles", timeout=self.timeout) - response.raise_for_status() - data = response.json() - return data.get("handles", []) - except Exception as e: - self._handle_request_error(e) - - def get_handle_info(self, handle_id: int) -> Dict[str, Any]: - """Get information about a specific handle - - Args: - handle_id: The handle ID (int64) - - Returns: - Handle info dict - """ - try: - response = self.session.get( - f"{self.api_base}/handles/{handle_id}", timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def close_handle(self, handle_id: int) -> Dict[str, Any]: - """Close a file handle - - Args: - handle_id: The handle ID (int64) to close - - Returns: - Response with message - """ - try: - response = self.session.delete( - f"{self.api_base}/handles/{handle_id}", timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def handle_read(self, handle_id: int, size: int = -1, offset: Optional[int] = None) -> bytes: - """Read from a file handle - - Args: - handle_id: The handle ID (int64) - size: Number of bytes to read (default: -1, read all) - offset: If specified, read at this offset (pread), otherwise read at current position - - Returns: - bytes content - """ - try: - params = {"size": str(size)} - if offset is not None: - params["offset"] = str(offset) - response = self.session.get( - f"{self.api_base}/handles/{handle_id}/read", params=params, timeout=self.timeout - ) - response.raise_for_status() - return response.content - except Exception as e: - self._handle_request_error(e) - - def handle_write(self, handle_id: int, data: bytes, offset: Optional[int] = None) -> int: - """Write to a file handle - - Args: - handle_id: The handle ID (int64) - data: Data to write - offset: If specified, write at this offset (pwrite), otherwise write at current position - - Returns: - Number of bytes written - """ - try: - params = {} - if offset is not None: - params["offset"] = str(offset) - response = self.session.put( - f"{self.api_base}/handles/{handle_id}/write", - params=params, - data=data, - timeout=self.timeout, - ) - response.raise_for_status() - result = response.json() - return result.get("bytes_written", 0) - except Exception as e: - self._handle_request_error(e) - - def handle_seek(self, handle_id: int, offset: int, whence: int = 0) -> int: - """Seek within a file handle - - Args: - handle_id: The handle ID (int64) - offset: Offset to seek to - whence: 0=SEEK_SET, 1=SEEK_CUR, 2=SEEK_END - - Returns: - New position - """ - try: - response = self.session.post( - f"{self.api_base}/handles/{handle_id}/seek", - params={"offset": str(offset), "whence": str(whence)}, - timeout=self.timeout, - ) - response.raise_for_status() - result = response.json() - return result.get("position", 0) - except Exception as e: - self._handle_request_error(e) - - def handle_sync(self, handle_id: int) -> Dict[str, Any]: - """Sync a file handle (flush to storage) - - Args: - handle_id: The handle ID (int64) - - Returns: - Response with message - """ - try: - response = self.session.post( - f"{self.api_base}/handles/{handle_id}/sync", timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def handle_stat(self, handle_id: int) -> Dict[str, Any]: - """Get file info via handle - - Args: - handle_id: The handle ID (int64) - - Returns: - File info dict - """ - try: - response = self.session.get( - f"{self.api_base}/handles/{handle_id}/stat", timeout=self.timeout - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - def renew_handle(self, handle_id: int, lease: int = 60) -> Dict[str, Any]: - """Renew the lease on a file handle - - Args: - handle_id: The handle ID (int64) - lease: New lease duration in seconds - - Returns: - Response with new expires_at - """ - try: - response = self.session.post( - f"{self.api_base}/handles/{handle_id}/renew", - params={"lease": str(lease)}, - timeout=self.timeout, - ) - response.raise_for_status() - return response.json() - except Exception as e: - self._handle_request_error(e) - - -class FileHandle: - """A file handle for stateful file operations - - Supports context manager protocol for automatic cleanup. - - Example: - >>> with client.open_handle("/memfs/file.txt", flags=2) as fh: - ... fh.write(b"Hello World") - ... fh.seek(0) - ... print(fh.read()) - """ - - # Open flag constants - O_RDONLY = 0 - O_WRONLY = 1 - O_RDWR = 2 - O_APPEND = 8 - O_CREATE = 16 - O_EXCL = 32 - O_TRUNC = 64 - - # Seek whence constants - SEEK_SET = 0 - SEEK_CUR = 1 - SEEK_END = 2 - - def __init__(self, client: AGFSClient, handle_id: int, path: str, flags: int): - self._client = client - self._handle_id = handle_id - self._path = path - self._flags = flags - self._closed = False - - @property - def handle_id(self) -> int: - """The handle ID (int64)""" - return self._handle_id - - @property - def path(self) -> str: - """The file path""" - return self._path - - @property - def flags(self) -> int: - """The open flags (numeric)""" - return self._flags - - @property - def closed(self) -> bool: - """Whether the handle is closed""" - return self._closed - - def read(self, size: int = -1) -> bytes: - """Read from current position - - Args: - size: Number of bytes to read (default: -1, read all) - - Returns: - bytes content - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_read(self._handle_id, size) - - def read_at(self, size: int, offset: int) -> bytes: - """Read at specific offset (pread) - - Args: - size: Number of bytes to read - offset: Offset to read from - - Returns: - bytes content - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_read(self._handle_id, size, offset) - - def write(self, data: bytes) -> int: - """Write at current position - - Args: - data: Data to write - - Returns: - Number of bytes written - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_write(self._handle_id, data) - - def write_at(self, data: bytes, offset: int) -> int: - """Write at specific offset (pwrite) - - Args: - data: Data to write - offset: Offset to write at - - Returns: - Number of bytes written - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_write(self._handle_id, data, offset) - - def seek(self, offset: int, whence: int = 0) -> int: - """Seek to position - - Args: - offset: Offset to seek to - whence: SEEK_SET(0), SEEK_CUR(1), or SEEK_END(2) - - Returns: - New position - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_seek(self._handle_id, offset, whence) - - def tell(self) -> int: - """Get current position - - Returns: - Current position - """ - return self.seek(0, self.SEEK_CUR) - - def sync(self) -> None: - """Flush data to storage""" - if self._closed: - raise AGFSClientError("Handle is closed") - self._client.handle_sync(self._handle_id) - - def stat(self) -> Dict[str, Any]: - """Get file info - - Returns: - File info dict - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.handle_stat(self._handle_id) - - def info(self) -> Dict[str, Any]: - """Get handle info - - Returns: - Handle info dict - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.get_handle_info(self._handle_id) - - def renew(self, lease: int = 60) -> Dict[str, Any]: - """Renew the handle lease - - Args: - lease: New lease duration in seconds - - Returns: - Response with new expires_at - """ - if self._closed: - raise AGFSClientError("Handle is closed") - return self._client.renew_handle(self._handle_id, lease) - - def close(self) -> None: - """Close the handle""" - if not self._closed: - self._client.close_handle(self._handle_id) - self._closed = True - - def __enter__(self) -> "FileHandle": - return self - - def __exit__(self, exc_type, exc_val, exc_tb) -> None: - self.close() - - def __repr__(self) -> str: - status = "closed" if self._closed else "open" - return f"FileHandle(id={self._handle_id}, path={self._path}, flags={self._flags}, {status})" diff --git a/third_party/agfs/agfs-sdk/python/pyagfs/exceptions.py b/third_party/agfs/agfs-sdk/python/pyagfs/exceptions.py deleted file mode 100644 index eefb720d4..000000000 --- a/third_party/agfs/agfs-sdk/python/pyagfs/exceptions.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Exception classes for pyagfs""" - - -class AGFSClientError(Exception): - """Base exception for AGFS client errors""" - pass - - -class AGFSConnectionError(AGFSClientError): - """Connection related errors""" - pass - - -class AGFSTimeoutError(AGFSClientError): - """Timeout errors""" - pass - - -class AGFSHTTPError(AGFSClientError): - """HTTP related errors""" - - def __init__(self, message, status_code=None): - super().__init__(message) - self.status_code = status_code - - -class AGFSNotSupportedError(AGFSClientError): - """Operation not supported by the server or filesystem (HTTP 501)""" - pass diff --git a/third_party/agfs/agfs-sdk/python/pyagfs/helpers.py b/third_party/agfs/agfs-sdk/python/pyagfs/helpers.py deleted file mode 100644 index a55d4ffdb..000000000 --- a/third_party/agfs/agfs-sdk/python/pyagfs/helpers.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Helper functions for common file operations in AGFS. - -This module provides high-level helper functions for common operations: -- cp: Copy files/directories within AGFS -- upload: Upload files/directories from local filesystem to AGFS -- download: Download files/directories from AGFS to local filesystem -""" - -import os -from pathlib import Path -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .client import AGFSClient - - -def cp(client: "AGFSClient", src: str, dst: str, recursive: bool = False, stream: bool = False) -> None: - """Copy a file or directory within AGFS. - - Args: - client: AGFSClient instance - src: Source path in AGFS - dst: Destination path in AGFS - recursive: If True, copy directories recursively - stream: If True, use streaming for large files (memory efficient) - - Raises: - AGFSClientError: If source doesn't exist or operation fails - - Examples: - >>> client = AGFSClient("http://localhost:8080") - >>> cp(client, "/file.txt", "/backup/file.txt") # Copy file - >>> cp(client, "/dir", "/backup/dir", recursive=True) # Copy directory - """ - # Check if source exists and get its type - src_info = client.stat(src) - is_dir = src_info.get('isDir', False) - - if is_dir: - if not recursive: - raise ValueError(f"Cannot copy directory '{src}' without recursive=True") - _copy_directory(client, src, dst, stream) - else: - _copy_file(client, src, dst, stream) - - -def upload(client: "AGFSClient", local_path: str, remote_path: str, recursive: bool = False, stream: bool = False) -> None: - """Upload a file or directory from local filesystem to AGFS. - - Args: - client: AGFSClient instance - local_path: Path to local file or directory - remote_path: Destination path in AGFS - recursive: If True, upload directories recursively - stream: If True, use streaming for large files (memory efficient) - - Raises: - FileNotFoundError: If local path doesn't exist - AGFSClientError: If upload fails - - Examples: - >>> client = AGFSClient("http://localhost:8080") - >>> upload(client, "/tmp/file.txt", "/remote/file.txt") # Upload file - >>> upload(client, "/tmp/data", "/remote/data", recursive=True) # Upload directory - """ - local = Path(local_path) - - if not local.exists(): - raise FileNotFoundError(f"Local path does not exist: {local_path}") - - if local.is_dir(): - if not recursive: - raise ValueError(f"Cannot upload directory '{local_path}' without recursive=True") - _upload_directory(client, local, remote_path, stream) - else: - _upload_file(client, local, remote_path, stream) - - -def download(client: "AGFSClient", remote_path: str, local_path: str, recursive: bool = False, stream: bool = False) -> None: - """Download a file or directory from AGFS to local filesystem. - - Args: - client: AGFSClient instance - remote_path: Path in AGFS - local_path: Destination path on local filesystem - recursive: If True, download directories recursively - stream: If True, use streaming for large files (memory efficient) - - Raises: - AGFSClientError: If remote path doesn't exist or download fails - - Examples: - >>> client = AGFSClient("http://localhost:8080") - >>> download(client, "/remote/file.txt", "/tmp/file.txt") # Download file - >>> download(client, "/remote/data", "/tmp/data", recursive=True) # Download directory - """ - # Check if remote path exists and get its type - remote_info = client.stat(remote_path) - is_dir = remote_info.get('isDir', False) - - if is_dir: - if not recursive: - raise ValueError(f"Cannot download directory '{remote_path}' without recursive=True") - _download_directory(client, remote_path, Path(local_path), stream) - else: - _download_file(client, remote_path, Path(local_path), stream) - - -# Internal helper functions - -def _copy_file(client: "AGFSClient", src: str, dst: str, stream: bool) -> None: - """Copy a single file within AGFS.""" - # Ensure parent directory exists - _ensure_remote_parent_dir(client, dst) - - if stream: - # Stream the file content for memory efficiency - response = client.cat(src, stream=True) - # Read and write in chunks - chunk_size = 8192 - chunks = [] - for chunk in response.iter_content(chunk_size=chunk_size): - chunks.append(chunk) - data = b''.join(chunks) - client.write(dst, data) - else: - # Read entire file and write - data = client.cat(src) - client.write(dst, data) - - -def _copy_directory(client: "AGFSClient", src: str, dst: str, stream: bool) -> None: - """Recursively copy a directory within AGFS.""" - # Create destination directory - try: - client.mkdir(dst) - except Exception: - # Directory might already exist, continue - pass - - # List source directory contents - items = client.ls(src) - - for item in items: - item_name = item['name'] - src_path = f"{src.rstrip('/')}/{item_name}" - dst_path = f"{dst.rstrip('/')}/{item_name}" - - if item.get('isDir', False): - # Recursively copy subdirectory - _copy_directory(client, src_path, dst_path, stream) - else: - # Copy file - _copy_file(client, src_path, dst_path, stream) - - -def _upload_file(client: "AGFSClient", local_file: Path, remote_path: str, stream: bool) -> None: - """Upload a single file to AGFS.""" - # Ensure parent directory exists in AGFS - _ensure_remote_parent_dir(client, remote_path) - - if stream: - # Read file in chunks for memory efficiency - chunk_size = 8192 - chunks = [] - with open(local_file, 'rb') as f: - while True: - chunk = f.read(chunk_size) - if not chunk: - break - chunks.append(chunk) - data = b''.join(chunks) - client.write(remote_path, data) - else: - # Read entire file - with open(local_file, 'rb') as f: - data = f.read() - client.write(remote_path, data) - - -def _upload_directory(client: "AGFSClient", local_dir: Path, remote_path: str, stream: bool) -> None: - """Recursively upload a directory to AGFS.""" - # Create remote directory - try: - client.mkdir(remote_path) - except Exception: - # Directory might already exist, continue - pass - - # Walk through local directory - for item in local_dir.iterdir(): - remote_item_path = f"{remote_path.rstrip('/')}/{item.name}" - - if item.is_dir(): - # Recursively upload subdirectory - _upload_directory(client, item, remote_item_path, stream) - else: - # Upload file - _upload_file(client, item, remote_item_path, stream) - - -def _download_file(client: "AGFSClient", remote_path: str, local_file: Path, stream: bool) -> None: - """Download a single file from AGFS.""" - # Ensure parent directory exists locally - local_file.parent.mkdir(parents=True, exist_ok=True) - - if stream: - # Stream the file content - response = client.cat(remote_path, stream=True) - with open(local_file, 'wb') as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - else: - # Read entire file - data = client.cat(remote_path) - with open(local_file, 'wb') as f: - f.write(data) - - -def _download_directory(client: "AGFSClient", remote_path: str, local_dir: Path, stream: bool) -> None: - """Recursively download a directory from AGFS.""" - # Create local directory - local_dir.mkdir(parents=True, exist_ok=True) - - # List remote directory contents - items = client.ls(remote_path) - - for item in items: - item_name = item['name'] - remote_item_path = f"{remote_path.rstrip('/')}/{item_name}" - local_item_path = local_dir / item_name - - if item.get('isDir', False): - # Recursively download subdirectory - _download_directory(client, remote_item_path, local_item_path, stream) - else: - # Download file - _download_file(client, remote_item_path, local_item_path, stream) - - -def _ensure_remote_parent_dir(client: "AGFSClient", path: str) -> None: - """Ensure the parent directory exists for a remote path.""" - parent = '/'.join(path.rstrip('/').split('/')[:-1]) - if parent and parent != '/': - # Try to create parent directory (and its parents) - _ensure_remote_dir_recursive(client, parent) - - -def _ensure_remote_dir_recursive(client: "AGFSClient", path: str) -> None: - """Recursively ensure a directory exists in AGFS.""" - if not path or path == '/': - return - - # Check if directory already exists - try: - info = client.stat(path) - if info.get('isDir', False): - return # Directory exists - except Exception: - # Directory doesn't exist, need to create it - pass - - # Ensure parent exists first - parent = '/'.join(path.rstrip('/').split('/')[:-1]) - if parent and parent != '/': - _ensure_remote_dir_recursive(client, parent) - - # Create this directory - try: - client.mkdir(path) - except Exception: - # Might already exist due to race condition, ignore - pass diff --git a/third_party/agfs/agfs-sdk/python/pyproject.toml b/third_party/agfs/agfs-sdk/python/pyproject.toml deleted file mode 100644 index 0a317944c..000000000 --- a/third_party/agfs/agfs-sdk/python/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "pyagfs" -version = "1.4.0" -description = "Python SDK for AGFS (Pluggable File System) Server" -readme = "README.md" -requires-python = ">=3.8" -authors = [ - { name = "agfs authors" } -] -dependencies = [ - "requests>=2.31.0", -] -keywords = ["agfs", "filesystem", "sdk", "client"] -classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", -] - -[project.optional-dependencies] -dev = [ - "pytest>=7.0.0", - "pytest-cov>=4.0.0", - "black>=23.0.0", - "ruff>=0.0.270", -] - -[tool.hatch.build.targets.wheel] -packages = ["pyagfs"] diff --git a/third_party/agfs/agfs-sdk/python/uv.lock b/third_party/agfs/agfs-sdk/python/uv.lock deleted file mode 100644 index 1f54bc49f..000000000 --- a/third_party/agfs/agfs-sdk/python/uv.lock +++ /dev/null @@ -1,1024 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.8" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] - -[[package]] -name = "black" -version = "24.8.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "mypy-extensions", marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pathspec", marker = "python_full_version < '3.9'" }, - { name = "platformdirs", version = "4.3.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/b0/46fb0d4e00372f4a86a6f8efa3cb193c9f64863615e39010b1477e010578/black-24.8.0.tar.gz", hash = "sha256:2500945420b6784c38b9ee885af039f5e7471ef284ab03fa35ecdde4688cd83f", size = 644810 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/6e/74e29edf1fba3887ed7066930a87f698ffdcd52c5dbc263eabb06061672d/black-24.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:09cdeb74d494ec023ded657f7092ba518e8cf78fa8386155e4a03fdcc44679e6", size = 1632092 }, - { url = "https://files.pythonhosted.org/packages/ab/49/575cb6c3faee690b05c9d11ee2e8dba8fbd6d6c134496e644c1feb1b47da/black-24.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81c6742da39f33b08e791da38410f32e27d632260e599df7245cccee2064afeb", size = 1457529 }, - { url = "https://files.pythonhosted.org/packages/7a/b4/d34099e95c437b53d01c4aa37cf93944b233066eb034ccf7897fa4e5f286/black-24.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:707a1ca89221bc8a1a64fb5e15ef39cd755633daa672a9db7498d1c19de66a42", size = 1757443 }, - { url = "https://files.pythonhosted.org/packages/87/a0/6d2e4175ef364b8c4b64f8441ba041ed65c63ea1db2720d61494ac711c15/black-24.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d6417535d99c37cee4091a2f24eb2b6d5ec42b144d50f1f2e436d9fe1916fe1a", size = 1418012 }, - { url = "https://files.pythonhosted.org/packages/08/a6/0a3aa89de9c283556146dc6dbda20cd63a9c94160a6fbdebaf0918e4a3e1/black-24.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb6e2c0b86bbd43dee042e48059c9ad7830abd5c94b0bc518c0eeec57c3eddc1", size = 1615080 }, - { url = "https://files.pythonhosted.org/packages/db/94/b803d810e14588bb297e565821a947c108390a079e21dbdcb9ab6956cd7a/black-24.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:837fd281f1908d0076844bc2b801ad2d369c78c45cf800cad7b61686051041af", size = 1438143 }, - { url = "https://files.pythonhosted.org/packages/a5/b5/f485e1bbe31f768e2e5210f52ea3f432256201289fd1a3c0afda693776b0/black-24.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62e8730977f0b77998029da7971fa896ceefa2c4c4933fcd593fa599ecbf97a4", size = 1738774 }, - { url = "https://files.pythonhosted.org/packages/a8/69/a000fc3736f89d1bdc7f4a879f8aaf516fb03613bb51a0154070383d95d9/black-24.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:72901b4913cbac8972ad911dc4098d5753704d1f3c56e44ae8dce99eecb0e3af", size = 1427503 }, - { url = "https://files.pythonhosted.org/packages/a2/a8/05fb14195cfef32b7c8d4585a44b7499c2a4b205e1662c427b941ed87054/black-24.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:7c046c1d1eeb7aea9335da62472481d3bbf3fd986e093cffd35f4385c94ae368", size = 1646132 }, - { url = "https://files.pythonhosted.org/packages/41/77/8d9ce42673e5cb9988f6df73c1c5c1d4e9e788053cccd7f5fb14ef100982/black-24.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:649f6d84ccbae73ab767e206772cc2d7a393a001070a4c814a546afd0d423aed", size = 1448665 }, - { url = "https://files.pythonhosted.org/packages/cc/94/eff1ddad2ce1d3cc26c162b3693043c6b6b575f538f602f26fe846dfdc75/black-24.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b59b250fdba5f9a9cd9d0ece6e6d993d91ce877d121d161e4698af3eb9c1018", size = 1762458 }, - { url = "https://files.pythonhosted.org/packages/28/ea/18b8d86a9ca19a6942e4e16759b2fa5fc02bbc0eb33c1b866fcd387640ab/black-24.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e55d30d44bed36593c3163b9bc63bf58b3b30e4611e4d88a0c3c239930ed5b2", size = 1436109 }, - { url = "https://files.pythonhosted.org/packages/9f/d4/ae03761ddecc1a37d7e743b89cccbcf3317479ff4b88cfd8818079f890d0/black-24.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:505289f17ceda596658ae81b61ebbe2d9b25aa78067035184ed0a9d855d18afd", size = 1617322 }, - { url = "https://files.pythonhosted.org/packages/14/4b/4dfe67eed7f9b1ddca2ec8e4418ea74f0d1dc84d36ea874d618ffa1af7d4/black-24.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b19c9ad992c7883ad84c9b22aaa73562a16b819c1d8db7a1a1a49fb7ec13c7d2", size = 1442108 }, - { url = "https://files.pythonhosted.org/packages/97/14/95b3f91f857034686cae0e73006b8391d76a8142d339b42970eaaf0416ea/black-24.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f13f7f386f86f8121d76599114bb8c17b69d962137fc70efe56137727c7047e", size = 1745786 }, - { url = "https://files.pythonhosted.org/packages/95/54/68b8883c8aa258a6dde958cd5bdfada8382bec47c5162f4a01e66d839af1/black-24.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:f490dbd59680d809ca31efdae20e634f3fae27fba3ce0ba3208333b713bc3920", size = 1426754 }, - { url = "https://files.pythonhosted.org/packages/13/b2/b3f24fdbb46f0e7ef6238e131f13572ee8279b70f237f221dd168a9dba1a/black-24.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:eab4dd44ce80dea27dc69db40dab62d4ca96112f87996bca68cd75639aeb2e4c", size = 1631706 }, - { url = "https://files.pythonhosted.org/packages/d9/35/31010981e4a05202a84a3116423970fd1a59d2eda4ac0b3570fbb7029ddc/black-24.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3c4285573d4897a7610054af5a890bde7c65cb466040c5f0c8b732812d7f0e5e", size = 1457429 }, - { url = "https://files.pythonhosted.org/packages/27/25/3f706b4f044dd569a20a4835c3b733dedea38d83d2ee0beb8178a6d44945/black-24.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e84e33b37be070ba135176c123ae52a51f82306def9f7d063ee302ecab2cf47", size = 1756488 }, - { url = "https://files.pythonhosted.org/packages/63/72/79375cd8277cbf1c5670914e6bd4c1b15dea2c8f8e906dc21c448d0535f0/black-24.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:73bbf84ed136e45d451a260c6b73ed674652f90a2b3211d6a35e78054563a9bb", size = 1417721 }, - { url = "https://files.pythonhosted.org/packages/27/1e/83fa8a787180e1632c3d831f7e58994d7aaf23a0961320d21e84f922f919/black-24.8.0-py3-none-any.whl", hash = "sha256:972085c618ee94f402da1af548a4f218c754ea7e5dc70acb168bfaca4c2542ed", size = 206504 }, -] - -[[package]] -name = "black" -version = "25.11.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "mypy-extensions", marker = "python_full_version >= '3.9'" }, - { name = "packaging", marker = "python_full_version >= '3.9'" }, - { name = "pathspec", marker = "python_full_version >= '3.9'" }, - { name = "platformdirs", version = "4.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "platformdirs", version = "4.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytokens", marker = "python_full_version >= '3.9'" }, - { name = "tomli", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8c/ad/33adf4708633d047950ff2dfdea2e215d84ac50ef95aff14a614e4b6e9b2/black-25.11.0.tar.gz", hash = "sha256:9a323ac32f5dc75ce7470501b887250be5005a01602e931a15e45593f70f6e08", size = 655669 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/d2/6caccbc96f9311e8ec3378c296d4f4809429c43a6cd2394e3c390e86816d/black-25.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ec311e22458eec32a807f029b2646f661e6859c3f61bc6d9ffb67958779f392e", size = 1743501 }, - { url = "https://files.pythonhosted.org/packages/69/35/b986d57828b3f3dccbf922e2864223197ba32e74c5004264b1c62bc9f04d/black-25.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1032639c90208c15711334d681de2e24821af0575573db2810b0763bcd62e0f0", size = 1597308 }, - { url = "https://files.pythonhosted.org/packages/39/8e/8b58ef4b37073f52b64a7b2dd8c9a96c84f45d6f47d878d0aa557e9a2d35/black-25.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c0f7c461df55cf32929b002335883946a4893d759f2df343389c4396f3b6b37", size = 1656194 }, - { url = "https://files.pythonhosted.org/packages/8d/30/9c2267a7955ecc545306534ab88923769a979ac20a27cf618d370091e5dd/black-25.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:f9786c24d8e9bd5f20dc7a7f0cdd742644656987f6ea6947629306f937726c03", size = 1347996 }, - { url = "https://files.pythonhosted.org/packages/c4/62/d304786b75ab0c530b833a89ce7d997924579fb7484ecd9266394903e394/black-25.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:895571922a35434a9d8ca67ef926da6bc9ad464522a5fe0db99b394ef1c0675a", size = 1727891 }, - { url = "https://files.pythonhosted.org/packages/82/5d/ffe8a006aa522c9e3f430e7b93568a7b2163f4b3f16e8feb6d8c3552761a/black-25.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cb4f4b65d717062191bdec8e4a442539a8ea065e6af1c4f4d36f0cdb5f71e170", size = 1581875 }, - { url = "https://files.pythonhosted.org/packages/cb/c8/7c8bda3108d0bb57387ac41b4abb5c08782b26da9f9c4421ef6694dac01a/black-25.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d81a44cbc7e4f73a9d6ae449ec2317ad81512d1e7dce7d57f6333fd6259737bc", size = 1642716 }, - { url = "https://files.pythonhosted.org/packages/34/b9/f17dea34eecb7cc2609a89627d480fb6caea7b86190708eaa7eb15ed25e7/black-25.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7eebd4744dfe92ef1ee349dc532defbf012a88b087bb7ddd688ff59a447b080e", size = 1352904 }, - { url = "https://files.pythonhosted.org/packages/7f/12/5c35e600b515f35ffd737da7febdb2ab66bb8c24d88560d5e3ef3d28c3fd/black-25.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:80e7486ad3535636657aa180ad32a7d67d7c273a80e12f1b4bfa0823d54e8fac", size = 1772831 }, - { url = "https://files.pythonhosted.org/packages/1a/75/b3896bec5a2bb9ed2f989a970ea40e7062f8936f95425879bbe162746fe5/black-25.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6cced12b747c4c76bc09b4db057c319d8545307266f41aaee665540bc0e04e96", size = 1608520 }, - { url = "https://files.pythonhosted.org/packages/f3/b5/2bfc18330eddbcfb5aab8d2d720663cd410f51b2ed01375f5be3751595b0/black-25.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb2d54a39e0ef021d6c5eef442e10fd71fcb491be6413d083a320ee768329dd", size = 1682719 }, - { url = "https://files.pythonhosted.org/packages/96/fb/f7dc2793a22cdf74a72114b5ed77fe3349a2e09ef34565857a2f917abdf2/black-25.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae263af2f496940438e5be1a0c1020e13b09154f3af4df0835ea7f9fe7bfa409", size = 1362684 }, - { url = "https://files.pythonhosted.org/packages/ad/47/3378d6a2ddefe18553d1115e36aea98f4a90de53b6a3017ed861ba1bd3bc/black-25.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a1d40348b6621cc20d3d7530a5b8d67e9714906dfd7346338249ad9c6cedf2b", size = 1772446 }, - { url = "https://files.pythonhosted.org/packages/ba/4b/0f00bfb3d1f7e05e25bfc7c363f54dc523bb6ba502f98f4ad3acf01ab2e4/black-25.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:51c65d7d60bb25429ea2bf0731c32b2a2442eb4bd3b2afcb47830f0b13e58bfd", size = 1607983 }, - { url = "https://files.pythonhosted.org/packages/99/fe/49b0768f8c9ae57eb74cc10a1f87b4c70453551d8ad498959721cc345cb7/black-25.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:936c4dd07669269f40b497440159a221ee435e3fddcf668e0c05244a9be71993", size = 1682481 }, - { url = "https://files.pythonhosted.org/packages/55/17/7e10ff1267bfa950cc16f0a411d457cdff79678fbb77a6c73b73a5317904/black-25.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:f42c0ea7f59994490f4dccd64e6b2dd49ac57c7c84f38b8faab50f8759db245c", size = 1363869 }, - { url = "https://files.pythonhosted.org/packages/67/c0/cc865ce594d09e4cd4dfca5e11994ebb51604328489f3ca3ae7bb38a7db5/black-25.11.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:35690a383f22dd3e468c85dc4b915217f87667ad9cce781d7b42678ce63c4170", size = 1771358 }, - { url = "https://files.pythonhosted.org/packages/37/77/4297114d9e2fd2fc8ab0ab87192643cd49409eb059e2940391e7d2340e57/black-25.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dae49ef7369c6caa1a1833fd5efb7c3024bb7e4499bf64833f65ad27791b1545", size = 1612902 }, - { url = "https://files.pythonhosted.org/packages/de/63/d45ef97ada84111e330b2b2d45e1dd163e90bd116f00ac55927fb6bf8adb/black-25.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bd4a22a0b37401c8e492e994bce79e614f91b14d9ea911f44f36e262195fdda", size = 1680571 }, - { url = "https://files.pythonhosted.org/packages/ff/4b/5604710d61cdff613584028b4cb4607e56e148801ed9b38ee7970799dab6/black-25.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:aa211411e94fdf86519996b7f5f05e71ba34835d8f0c0f03c00a26271da02664", size = 1382599 }, - { url = "https://files.pythonhosted.org/packages/d5/9a/5b2c0e3215fe748fcf515c2dd34658973a1210bf610e24de5ba887e4f1c8/black-25.11.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a3bb5ce32daa9ff0605d73b6f19da0b0e6c1f8f2d75594db539fdfed722f2b06", size = 1743063 }, - { url = "https://files.pythonhosted.org/packages/a1/20/245164c6efc27333409c62ba54dcbfbe866c6d1957c9a6c0647786e950da/black-25.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9815ccee1e55717fe9a4b924cae1646ef7f54e0f990da39a34fc7b264fcf80a2", size = 1596867 }, - { url = "https://files.pythonhosted.org/packages/ca/6f/1a3859a7da205f3d50cf3a8bec6bdc551a91c33ae77a045bb24c1f46ab54/black-25.11.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92285c37b93a1698dcbc34581867b480f1ba3a7b92acf1fe0467b04d7a4da0dc", size = 1655678 }, - { url = "https://files.pythonhosted.org/packages/56/1a/6dec1aeb7be90753d4fcc273e69bc18bfd34b353223ed191da33f7519410/black-25.11.0-cp39-cp39-win_amd64.whl", hash = "sha256:43945853a31099c7c0ff8dface53b4de56c41294fa6783c0441a8b1d9bf668bc", size = 1347452 }, - { url = "https://files.pythonhosted.org/packages/00/5d/aed32636ed30a6e7f9efd6ad14e2a0b0d687ae7c8c7ec4e4a557174b895c/black-25.11.0-py3-none-any.whl", hash = "sha256:e3f562da087791e96cefcd9dda058380a442ab322a02e222add53736451f604b", size = 204918 }, -] - -[[package]] -name = "certifi" -version = "2025.10.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286 }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, - { url = "https://files.pythonhosted.org/packages/0a/4e/3926a1c11f0433791985727965263f788af00db3482d89a7545ca5ecc921/charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", size = 198599 }, - { url = "https://files.pythonhosted.org/packages/ec/7c/b92d1d1dcffc34592e71ea19c882b6709e43d20fa498042dea8b815638d7/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", size = 143090 }, - { url = "https://files.pythonhosted.org/packages/84/ce/61a28d3bb77281eb24107b937a497f3c43089326d27832a63dcedaab0478/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", size = 139490 }, - { url = "https://files.pythonhosted.org/packages/c0/bd/c9e59a91b2061c6f8bb98a150670cb16d4cd7c4ba7d11ad0cdf789155f41/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", size = 155334 }, - { url = "https://files.pythonhosted.org/packages/bf/37/f17ae176a80f22ff823456af91ba3bc59df308154ff53aef0d39eb3d3419/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", size = 152823 }, - { url = "https://files.pythonhosted.org/packages/bf/fa/cf5bb2409a385f78750e78c8d2e24780964976acdaaed65dbd6083ae5b40/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", size = 147618 }, - { url = "https://files.pythonhosted.org/packages/9b/63/579784a65bc7de2d4518d40bb8f1870900163e86f17f21fd1384318c459d/charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", size = 145516 }, - { url = "https://files.pythonhosted.org/packages/a3/a9/94ec6266cd394e8f93a4d69cca651d61bf6ac58d2a0422163b30c698f2c7/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", size = 145266 }, - { url = "https://files.pythonhosted.org/packages/09/14/d6626eb97764b58c2779fa7928fa7d1a49adb8ce687c2dbba4db003c1939/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", size = 139559 }, - { url = "https://files.pythonhosted.org/packages/09/01/ddbe6b01313ba191dbb0a43c7563bc770f2448c18127f9ea4b119c44dff0/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", size = 156653 }, - { url = "https://files.pythonhosted.org/packages/95/c8/d05543378bea89296e9af4510b44c704626e191da447235c8fdedfc5b7b2/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", size = 145644 }, - { url = "https://files.pythonhosted.org/packages/72/01/2866c4377998ef8a1f6802f6431e774a4c8ebe75b0a6e569ceec55c9cbfb/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", size = 153964 }, - { url = "https://files.pythonhosted.org/packages/4a/66/66c72468a737b4cbd7851ba2c522fe35c600575fbeac944460b4fd4a06fe/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", size = 148777 }, - { url = "https://files.pythonhosted.org/packages/50/94/d0d56677fdddbffa8ca00ec411f67bb8c947f9876374ddc9d160d4f2c4b3/charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", size = 98687 }, - { url = "https://files.pythonhosted.org/packages/00/64/c3bc303d1b586480b1c8e6e1e2191a6d6dd40255244e5cf16763dcec52e6/charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", size = 106115 }, - { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609 }, - { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029 }, - { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580 }, - { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340 }, - { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619 }, - { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980 }, - { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174 }, - { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666 }, - { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550 }, - { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721 }, - { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127 }, - { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175 }, - { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375 }, - { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692 }, - { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192 }, - { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220 }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "click" -version = "8.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "coverage" -version = "7.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690 }, - { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127 }, - { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654 }, - { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598 }, - { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732 }, - { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816 }, - { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325 }, - { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418 }, - { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343 }, - { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136 }, - { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796 }, - { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244 }, - { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279 }, - { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859 }, - { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549 }, - { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477 }, - { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134 }, - { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910 }, - { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348 }, - { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230 }, - { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983 }, - { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221 }, - { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342 }, - { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371 }, - { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455 }, - { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924 }, - { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252 }, - { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897 }, - { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606 }, - { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373 }, - { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007 }, - { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269 }, - { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886 }, - { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037 }, - { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038 }, - { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690 }, - { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765 }, - { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611 }, - { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671 }, - { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368 }, - { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758 }, - { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035 }, - { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839 }, - { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569 }, - { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927 }, - { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401 }, - { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301 }, - { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598 }, - { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307 }, - { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453 }, - { url = "https://files.pythonhosted.org/packages/81/d0/d9e3d554e38beea5a2e22178ddb16587dbcbe9a1ef3211f55733924bf7fa/coverage-7.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6db04803b6c7291985a761004e9060b2bca08da6d04f26a7f2294b8623a0c1a0", size = 206674 }, - { url = "https://files.pythonhosted.org/packages/38/ea/cab2dc248d9f45b2b7f9f1f596a4d75a435cb364437c61b51d2eb33ceb0e/coverage-7.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f1adfc8ac319e1a348af294106bc6a8458a0f1633cc62a1446aebc30c5fa186a", size = 207101 }, - { url = "https://files.pythonhosted.org/packages/ca/6f/f82f9a500c7c5722368978a5390c418d2a4d083ef955309a8748ecaa8920/coverage-7.6.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95324a9de9650a729239daea117df21f4b9868ce32e63f8b650ebe6cef5595b", size = 236554 }, - { url = "https://files.pythonhosted.org/packages/a6/94/d3055aa33d4e7e733d8fa309d9adf147b4b06a82c1346366fc15a2b1d5fa/coverage-7.6.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b43c03669dc4618ec25270b06ecd3ee4fa94c7f9b3c14bae6571ca00ef98b0d3", size = 234440 }, - { url = "https://files.pythonhosted.org/packages/e4/6e/885bcd787d9dd674de4a7d8ec83faf729534c63d05d51d45d4fa168f7102/coverage-7.6.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8929543a7192c13d177b770008bc4e8119f2e1f881d563fc6b6305d2d0ebe9de", size = 235889 }, - { url = "https://files.pythonhosted.org/packages/f4/63/df50120a7744492710854860783d6819ff23e482dee15462c9a833cc428a/coverage-7.6.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:a09ece4a69cf399510c8ab25e0950d9cf2b42f7b3cb0374f95d2e2ff594478a6", size = 235142 }, - { url = "https://files.pythonhosted.org/packages/3a/5d/9d0acfcded2b3e9ce1c7923ca52ccc00c78a74e112fc2aee661125b7843b/coverage-7.6.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:9054a0754de38d9dbd01a46621636689124d666bad1936d76c0341f7d71bf569", size = 233805 }, - { url = "https://files.pythonhosted.org/packages/c4/56/50abf070cb3cd9b1dd32f2c88f083aab561ecbffbcd783275cb51c17f11d/coverage-7.6.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0dbde0f4aa9a16fa4d754356a8f2e36296ff4d83994b2c9d8398aa32f222f989", size = 234655 }, - { url = "https://files.pythonhosted.org/packages/25/ee/b4c246048b8485f85a2426ef4abab88e48c6e80c74e964bea5cd4cd4b115/coverage-7.6.1-cp38-cp38-win32.whl", hash = "sha256:da511e6ad4f7323ee5702e6633085fb76c2f893aaf8ce4c51a0ba4fc07580ea7", size = 209296 }, - { url = "https://files.pythonhosted.org/packages/5c/1c/96cf86b70b69ea2b12924cdf7cabb8ad10e6130eab8d767a1099fbd2a44f/coverage-7.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:3f1156e3e8f2872197af3840d8ad307a9dd18e615dc64d9ee41696f287c57ad8", size = 210137 }, - { url = "https://files.pythonhosted.org/packages/19/d3/d54c5aa83268779d54c86deb39c1c4566e5d45c155369ca152765f8db413/coverage-7.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abd5fd0db5f4dc9289408aaf34908072f805ff7792632250dcb36dc591d24255", size = 206688 }, - { url = "https://files.pythonhosted.org/packages/a5/fe/137d5dca72e4a258b1bc17bb04f2e0196898fe495843402ce826a7419fe3/coverage-7.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:547f45fa1a93154bd82050a7f3cddbc1a7a4dd2a9bf5cb7d06f4ae29fe94eaf8", size = 207120 }, - { url = "https://files.pythonhosted.org/packages/78/5b/a0a796983f3201ff5485323b225d7c8b74ce30c11f456017e23d8e8d1945/coverage-7.6.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645786266c8f18a931b65bfcefdbf6952dd0dea98feee39bd188607a9d307ed2", size = 235249 }, - { url = "https://files.pythonhosted.org/packages/4e/e1/76089d6a5ef9d68f018f65411fcdaaeb0141b504587b901d74e8587606ad/coverage-7.6.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e0b2df163b8ed01d515807af24f63de04bebcecbd6c3bfeff88385789fdf75a", size = 233237 }, - { url = "https://files.pythonhosted.org/packages/9a/6f/eef79b779a540326fee9520e5542a8b428cc3bfa8b7c8f1022c1ee4fc66c/coverage-7.6.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:609b06f178fe8e9f89ef676532760ec0b4deea15e9969bf754b37f7c40326dbc", size = 234311 }, - { url = "https://files.pythonhosted.org/packages/75/e1/656d65fb126c29a494ef964005702b012f3498db1a30dd562958e85a4049/coverage-7.6.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:702855feff378050ae4f741045e19a32d57d19f3e0676d589df0575008ea5004", size = 233453 }, - { url = "https://files.pythonhosted.org/packages/68/6a/45f108f137941a4a1238c85f28fd9d048cc46b5466d6b8dda3aba1bb9d4f/coverage-7.6.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:2bdb062ea438f22d99cba0d7829c2ef0af1d768d1e4a4f528087224c90b132cb", size = 231958 }, - { url = "https://files.pythonhosted.org/packages/9b/e7/47b809099168b8b8c72ae311efc3e88c8d8a1162b3ba4b8da3cfcdb85743/coverage-7.6.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9c56863d44bd1c4fe2abb8a4d6f5371d197f1ac0ebdee542f07f35895fc07f36", size = 232938 }, - { url = "https://files.pythonhosted.org/packages/52/80/052222ba7058071f905435bad0ba392cc12006380731c37afaf3fe749b88/coverage-7.6.1-cp39-cp39-win32.whl", hash = "sha256:6e2cd258d7d927d09493c8df1ce9174ad01b381d4729a9d8d4e38670ca24774c", size = 209352 }, - { url = "https://files.pythonhosted.org/packages/b8/d8/1b92e0b3adcf384e98770a00ca095da1b5f7b483e6563ae4eb5e935d24a1/coverage-7.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:06a737c882bd26d0d6ee7269b20b12f14a8704807a01056c80bb881a4b2ce6ca", size = 210153 }, - { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926 }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version < '3.9'" }, -] - -[[package]] -name = "coverage" -version = "7.10.7" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/6c/3a3f7a46888e69d18abe3ccc6fe4cb16cccb1e6a2f99698931dafca489e6/coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a", size = 217987 }, - { url = "https://files.pythonhosted.org/packages/03/94/952d30f180b1a916c11a56f5c22d3535e943aa22430e9e3322447e520e1c/coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5", size = 218388 }, - { url = "https://files.pythonhosted.org/packages/50/2b/9e0cf8ded1e114bcd8b2fd42792b57f1c4e9e4ea1824cde2af93a67305be/coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17", size = 245148 }, - { url = "https://files.pythonhosted.org/packages/19/20/d0384ac06a6f908783d9b6aa6135e41b093971499ec488e47279f5b846e6/coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b", size = 246958 }, - { url = "https://files.pythonhosted.org/packages/60/83/5c283cff3d41285f8eab897651585db908a909c572bdc014bcfaf8a8b6ae/coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87", size = 248819 }, - { url = "https://files.pythonhosted.org/packages/60/22/02eb98fdc5ff79f423e990d877693e5310ae1eab6cb20ae0b0b9ac45b23b/coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e", size = 245754 }, - { url = "https://files.pythonhosted.org/packages/b4/bc/25c83bcf3ad141b32cd7dc45485ef3c01a776ca3aa8ef0a93e77e8b5bc43/coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e", size = 246860 }, - { url = "https://files.pythonhosted.org/packages/3c/b7/95574702888b58c0928a6e982038c596f9c34d52c5e5107f1eef729399b5/coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df", size = 244877 }, - { url = "https://files.pythonhosted.org/packages/47/b6/40095c185f235e085df0e0b158f6bd68cc6e1d80ba6c7721dc81d97ec318/coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0", size = 245108 }, - { url = "https://files.pythonhosted.org/packages/c8/50/4aea0556da7a4b93ec9168420d170b55e2eb50ae21b25062513d020c6861/coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13", size = 245752 }, - { url = "https://files.pythonhosted.org/packages/6a/28/ea1a84a60828177ae3b100cb6723838523369a44ec5742313ed7db3da160/coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b", size = 220497 }, - { url = "https://files.pythonhosted.org/packages/fc/1a/a81d46bbeb3c3fd97b9602ebaa411e076219a150489bcc2c025f151bd52d/coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807", size = 221392 }, - { url = "https://files.pythonhosted.org/packages/d2/5d/c1a17867b0456f2e9ce2d8d4708a4c3a089947d0bec9c66cdf60c9e7739f/coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59", size = 218102 }, - { url = "https://files.pythonhosted.org/packages/54/f0/514dcf4b4e3698b9a9077f084429681bf3aad2b4a72578f89d7f643eb506/coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a", size = 218505 }, - { url = "https://files.pythonhosted.org/packages/20/f6/9626b81d17e2a4b25c63ac1b425ff307ecdeef03d67c9a147673ae40dc36/coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699", size = 248898 }, - { url = "https://files.pythonhosted.org/packages/b0/ef/bd8e719c2f7417ba03239052e099b76ea1130ac0cbb183ee1fcaa58aaff3/coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d", size = 250831 }, - { url = "https://files.pythonhosted.org/packages/a5/b6/bf054de41ec948b151ae2b79a55c107f5760979538f5fb80c195f2517718/coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e", size = 252937 }, - { url = "https://files.pythonhosted.org/packages/0f/e5/3860756aa6f9318227443c6ce4ed7bf9e70bb7f1447a0353f45ac5c7974b/coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23", size = 249021 }, - { url = "https://files.pythonhosted.org/packages/26/0f/bd08bd042854f7fd07b45808927ebcce99a7ed0f2f412d11629883517ac2/coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab", size = 250626 }, - { url = "https://files.pythonhosted.org/packages/8e/a7/4777b14de4abcc2e80c6b1d430f5d51eb18ed1d75fca56cbce5f2db9b36e/coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82", size = 248682 }, - { url = "https://files.pythonhosted.org/packages/34/72/17d082b00b53cd45679bad682fac058b87f011fd8b9fe31d77f5f8d3a4e4/coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2", size = 248402 }, - { url = "https://files.pythonhosted.org/packages/81/7a/92367572eb5bdd6a84bfa278cc7e97db192f9f45b28c94a9ca1a921c3577/coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61", size = 249320 }, - { url = "https://files.pythonhosted.org/packages/2f/88/a23cc185f6a805dfc4fdf14a94016835eeb85e22ac3a0e66d5e89acd6462/coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14", size = 220536 }, - { url = "https://files.pythonhosted.org/packages/fe/ef/0b510a399dfca17cec7bc2f05ad8bd78cf55f15c8bc9a73ab20c5c913c2e/coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2", size = 221425 }, - { url = "https://files.pythonhosted.org/packages/51/7f/023657f301a276e4ba1850f82749bc136f5a7e8768060c2e5d9744a22951/coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a", size = 220103 }, - { url = "https://files.pythonhosted.org/packages/13/e4/eb12450f71b542a53972d19117ea5a5cea1cab3ac9e31b0b5d498df1bd5a/coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417", size = 218290 }, - { url = "https://files.pythonhosted.org/packages/37/66/593f9be12fc19fb36711f19a5371af79a718537204d16ea1d36f16bd78d2/coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973", size = 218515 }, - { url = "https://files.pythonhosted.org/packages/66/80/4c49f7ae09cafdacc73fbc30949ffe77359635c168f4e9ff33c9ebb07838/coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c", size = 250020 }, - { url = "https://files.pythonhosted.org/packages/a6/90/a64aaacab3b37a17aaedd83e8000142561a29eb262cede42d94a67f7556b/coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7", size = 252769 }, - { url = "https://files.pythonhosted.org/packages/98/2e/2dda59afd6103b342e096f246ebc5f87a3363b5412609946c120f4e7750d/coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6", size = 253901 }, - { url = "https://files.pythonhosted.org/packages/53/dc/8d8119c9051d50f3119bb4a75f29f1e4a6ab9415cd1fa8bf22fcc3fb3b5f/coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59", size = 250413 }, - { url = "https://files.pythonhosted.org/packages/98/b3/edaff9c5d79ee4d4b6d3fe046f2b1d799850425695b789d491a64225d493/coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b", size = 251820 }, - { url = "https://files.pythonhosted.org/packages/11/25/9a0728564bb05863f7e513e5a594fe5ffef091b325437f5430e8cfb0d530/coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a", size = 249941 }, - { url = "https://files.pythonhosted.org/packages/e0/fd/ca2650443bfbef5b0e74373aac4df67b08180d2f184b482c41499668e258/coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb", size = 249519 }, - { url = "https://files.pythonhosted.org/packages/24/79/f692f125fb4299b6f963b0745124998ebb8e73ecdfce4ceceb06a8c6bec5/coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1", size = 251375 }, - { url = "https://files.pythonhosted.org/packages/5e/75/61b9bbd6c7d24d896bfeec57acba78e0f8deac68e6baf2d4804f7aae1f88/coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256", size = 220699 }, - { url = "https://files.pythonhosted.org/packages/ca/f3/3bf7905288b45b075918d372498f1cf845b5b579b723c8fd17168018d5f5/coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba", size = 221512 }, - { url = "https://files.pythonhosted.org/packages/5c/44/3e32dbe933979d05cf2dac5e697c8599cfe038aaf51223ab901e208d5a62/coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf", size = 220147 }, - { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320 }, - { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575 }, - { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568 }, - { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174 }, - { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447 }, - { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779 }, - { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604 }, - { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497 }, - { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350 }, - { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111 }, - { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746 }, - { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541 }, - { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170 }, - { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029 }, - { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259 }, - { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592 }, - { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768 }, - { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995 }, - { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546 }, - { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544 }, - { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308 }, - { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920 }, - { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434 }, - { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403 }, - { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469 }, - { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731 }, - { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302 }, - { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578 }, - { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629 }, - { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162 }, - { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517 }, - { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632 }, - { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520 }, - { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455 }, - { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287 }, - { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946 }, - { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009 }, - { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804 }, - { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384 }, - { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047 }, - { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266 }, - { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767 }, - { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931 }, - { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186 }, - { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470 }, - { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626 }, - { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386 }, - { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852 }, - { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534 }, - { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784 }, - { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905 }, - { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922 }, - { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978 }, - { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370 }, - { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802 }, - { url = "https://files.pythonhosted.org/packages/b0/49/8a070782ce7e6b94ff6a0b6d7c65ba6bc3091d92a92cef4cd4eb0767965c/coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40", size = 246625 }, - { url = "https://files.pythonhosted.org/packages/6a/92/1c1c5a9e8677ce56d42b97bdaca337b2d4d9ebe703d8c174ede52dbabd5f/coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594", size = 248399 }, - { url = "https://files.pythonhosted.org/packages/c0/54/b140edee7257e815de7426d5d9846b58505dffc29795fff2dfb7f8a1c5a0/coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a", size = 245142 }, - { url = "https://files.pythonhosted.org/packages/e4/9e/6d6b8295940b118e8b7083b29226c71f6154f7ff41e9ca431f03de2eac0d/coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b", size = 246284 }, - { url = "https://files.pythonhosted.org/packages/db/e5/5e957ca747d43dbe4d9714358375c7546cb3cb533007b6813fc20fce37ad/coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3", size = 244353 }, - { url = "https://files.pythonhosted.org/packages/9a/45/540fc5cc92536a1b783b7ef99450bd55a4b3af234aae35a18a339973ce30/coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0", size = 244430 }, - { url = "https://files.pythonhosted.org/packages/75/0b/8287b2e5b38c8fe15d7e3398849bb58d382aedc0864ea0fa1820e8630491/coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f", size = 245311 }, - { url = "https://files.pythonhosted.org/packages/0c/1d/29724999984740f0c86d03e6420b942439bf5bd7f54d4382cae386a9d1e9/coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431", size = 220500 }, - { url = "https://files.pythonhosted.org/packages/43/11/4b1e6b129943f905ca54c339f343877b55b365ae2558806c1be4f7476ed5/coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07", size = 221408 }, - { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952 }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version == '3.9.*'" }, -] - -[[package]] -name = "coverage" -version = "7.11.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/d2/59/9698d57a3b11704c7b89b21d69e9d23ecf80d538cabb536c8b63f4a12322/coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b", size = 815210 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/68/b53157115ef76d50d1d916d6240e5cd5b3c14dba8ba1b984632b8221fc2e/coverage-7.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c986537abca9b064510f3fd104ba33e98d3036608c7f2f5537f869bc10e1ee5", size = 216377 }, - { url = "https://files.pythonhosted.org/packages/14/c1/d2f9d8e37123fe6e7ab8afcaab8195f13bc84a8b2f449a533fd4812ac724/coverage-7.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28c5251b3ab1d23e66f1130ca0c419747edfbcb4690de19467cd616861507af7", size = 216892 }, - { url = "https://files.pythonhosted.org/packages/83/73/18f05d8010149b650ed97ee5c9f7e4ae68c05c7d913391523281e41c2495/coverage-7.11.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4f2bb4ee8dd40f9b2a80bb4adb2aecece9480ba1fa60d9382e8c8e0bd558e2eb", size = 243650 }, - { url = "https://files.pythonhosted.org/packages/63/3c/c0cbb296c0ecc6dcbd70f4b473fcd7fe4517bbef8b09f4326d78f38adb87/coverage-7.11.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e5f4bfac975a2138215a38bda599ef00162e4143541cf7dd186da10a7f8e69f1", size = 245478 }, - { url = "https://files.pythonhosted.org/packages/b9/9a/dad288cf9faa142a14e75e39dc646d968b93d74e15c83e9b13fd628f2cb3/coverage-7.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f4cbfff5cf01fa07464439a8510affc9df281535f41a1f5312fbd2b59b4ab5c", size = 247337 }, - { url = "https://files.pythonhosted.org/packages/e3/ba/f6148ebf5547b3502013175e41bf3107a4e34b7dd19f9793a6ce0e1cd61f/coverage-7.11.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:31663572f20bf3406d7ac00d6981c7bbbcec302539d26b5ac596ca499664de31", size = 244328 }, - { url = "https://files.pythonhosted.org/packages/e6/4d/b93784d0b593c5df89a0d48cbbd2d0963e0ca089eaf877405849792e46d3/coverage-7.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9799bd6a910961cb666196b8583ed0ee125fa225c6fdee2cbf00232b861f29d2", size = 245381 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/6735bfd4f0f736d457642ee056a570d704c9d57fdcd5c91ea5d6b15c944e/coverage-7.11.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:097acc18bedf2c6e3144eaf09b5f6034926c3c9bb9e10574ffd0942717232507", size = 243390 }, - { url = "https://files.pythonhosted.org/packages/db/3d/7ba68ed52d1873d450aefd8d2f5a353e67b421915cb6c174e4222c7b918c/coverage-7.11.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6f033dec603eea88204589175782290a038b436105a8f3637a81c4359df27832", size = 243654 }, - { url = "https://files.pythonhosted.org/packages/14/26/be2720c4c7bf73c6591ae4ab503a7b5a31c7a60ced6dba855cfcb4a5af7e/coverage-7.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ca2d44ed8018c90efb72f237a2a140325a4c3339971364d758e78b175f58e", size = 244272 }, - { url = "https://files.pythonhosted.org/packages/90/20/086f5697780df146dbc0df4ae9b6db2b23ddf5aa550f977b2825137728e9/coverage-7.11.3-cp310-cp310-win32.whl", hash = "sha256:900580bc99c145e2561ea91a2d207e639171870d8a18756eb57db944a017d4bb", size = 218969 }, - { url = "https://files.pythonhosted.org/packages/98/5c/cc6faba945ede5088156da7770e30d06c38b8591785ac99bcfb2074f9ef6/coverage-7.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:c8be5bfcdc7832011b2652db29ed7672ce9d353dd19bce5272ca33dbcf60aaa8", size = 219903 }, - { url = "https://files.pythonhosted.org/packages/92/92/43a961c0f57b666d01c92bcd960c7f93677de5e4ee7ca722564ad6dee0fa/coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1", size = 216504 }, - { url = "https://files.pythonhosted.org/packages/5d/5c/dbfc73329726aef26dbf7fefef81b8a2afd1789343a579ea6d99bf15d26e/coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06", size = 217006 }, - { url = "https://files.pythonhosted.org/packages/a5/e0/878c84fb6661964bc435beb1e28c050650aa30e4c1cdc12341e298700bda/coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80", size = 247415 }, - { url = "https://files.pythonhosted.org/packages/56/9e/0677e78b1e6a13527f39c4b39c767b351e256b333050539861c63f98bd61/coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa", size = 249332 }, - { url = "https://files.pythonhosted.org/packages/54/90/25fc343e4ce35514262451456de0953bcae5b37dda248aed50ee51234cee/coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297", size = 251443 }, - { url = "https://files.pythonhosted.org/packages/13/56/bc02bbc890fd8b155a64285c93e2ab38647486701ac9c980d457cdae857a/coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362", size = 247554 }, - { url = "https://files.pythonhosted.org/packages/0f/ab/0318888d091d799a82d788c1e8d8bd280f1d5c41662bbb6e11187efe33e8/coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87", size = 249139 }, - { url = "https://files.pythonhosted.org/packages/79/d8/3ee50929c4cd36fcfcc0f45d753337001001116c8a5b8dd18d27ea645737/coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200", size = 247209 }, - { url = "https://files.pythonhosted.org/packages/94/7c/3cf06e327401c293e60c962b4b8a2ceb7167c1a428a02be3adbd1d7c7e4c/coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4", size = 246936 }, - { url = "https://files.pythonhosted.org/packages/99/0b/ffc03dc8f4083817900fd367110015ef4dd227b37284104a5eb5edc9c106/coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060", size = 247835 }, - { url = "https://files.pythonhosted.org/packages/17/4d/dbe54609ee066553d0bcdcdf108b177c78dab836292bee43f96d6a5674d1/coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7", size = 218994 }, - { url = "https://files.pythonhosted.org/packages/94/11/8e7155df53f99553ad8114054806c01a2c0b08f303ea7e38b9831652d83d/coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55", size = 219926 }, - { url = "https://files.pythonhosted.org/packages/1f/93/bea91b6a9e35d89c89a1cd5824bc72e45151a9c2a9ca0b50d9e9a85e3ae3/coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc", size = 218599 }, - { url = "https://files.pythonhosted.org/packages/c2/39/af056ec7a27c487e25c7f6b6e51d2ee9821dba1863173ddf4dc2eebef4f7/coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f", size = 216676 }, - { url = "https://files.pythonhosted.org/packages/3c/f8/21126d34b174d037b5d01bea39077725cbb9a0da94a95c5f96929c695433/coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e", size = 217034 }, - { url = "https://files.pythonhosted.org/packages/d5/3f/0fd35f35658cdd11f7686303214bd5908225838f374db47f9e457c8d6df8/coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a", size = 248531 }, - { url = "https://files.pythonhosted.org/packages/8f/59/0bfc5900fc15ce4fd186e092451de776bef244565c840c9c026fd50857e1/coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1", size = 251290 }, - { url = "https://files.pythonhosted.org/packages/71/88/d5c184001fa2ac82edf1b8f2cd91894d2230d7c309e937c54c796176e35b/coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd", size = 252375 }, - { url = "https://files.pythonhosted.org/packages/5c/29/f60af9f823bf62c7a00ce1ac88441b9a9a467e499493e5cc65028c8b8dd2/coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5", size = 248946 }, - { url = "https://files.pythonhosted.org/packages/67/16/4662790f3b1e03fce5280cad93fd18711c35980beb3c6f28dca41b5230c6/coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e", size = 250310 }, - { url = "https://files.pythonhosted.org/packages/8f/75/dd6c2e28308a83e5fc1ee602f8204bd3aa5af685c104cb54499230cf56db/coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044", size = 248461 }, - { url = "https://files.pythonhosted.org/packages/16/fe/b71af12be9f59dc9eb060688fa19a95bf3223f56c5af1e9861dfa2275d2c/coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7", size = 248039 }, - { url = "https://files.pythonhosted.org/packages/11/b8/023b2003a2cd96bdf607afe03d9b96c763cab6d76e024abe4473707c4eb8/coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405", size = 249903 }, - { url = "https://files.pythonhosted.org/packages/d6/ee/5f1076311aa67b1fa4687a724cc044346380e90ce7d94fec09fd384aa5fd/coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e", size = 219201 }, - { url = "https://files.pythonhosted.org/packages/4f/24/d21688f48fe9fcc778956680fd5aaf69f4e23b245b7c7a4755cbd421d25b/coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055", size = 220012 }, - { url = "https://files.pythonhosted.org/packages/4f/9e/d5eb508065f291456378aa9b16698b8417d87cb084c2b597f3beb00a8084/coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f", size = 218652 }, - { url = "https://files.pythonhosted.org/packages/6d/f6/d8572c058211c7d976f24dab71999a565501fb5b3cdcb59cf782f19c4acb/coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36", size = 216694 }, - { url = "https://files.pythonhosted.org/packages/4a/f6/b6f9764d90c0ce1bce8d995649fa307fff21f4727b8d950fa2843b7b0de5/coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e", size = 217065 }, - { url = "https://files.pythonhosted.org/packages/a5/8d/a12cb424063019fd077b5be474258a0ed8369b92b6d0058e673f0a945982/coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2", size = 248062 }, - { url = "https://files.pythonhosted.org/packages/7f/9c/dab1a4e8e75ce053d14259d3d7485d68528a662e286e184685ea49e71156/coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63", size = 250657 }, - { url = "https://files.pythonhosted.org/packages/3f/89/a14f256438324f33bae36f9a1a7137729bf26b0a43f5eda60b147ec7c8c7/coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3", size = 251900 }, - { url = "https://files.pythonhosted.org/packages/04/07/75b0d476eb349f1296486b1418b44f2d8780cc8db47493de3755e5340076/coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5", size = 248254 }, - { url = "https://files.pythonhosted.org/packages/5a/4b/0c486581fa72873489ca092c52792d008a17954aa352809a7cbe6cf0bf07/coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5", size = 250041 }, - { url = "https://files.pythonhosted.org/packages/af/a3/0059dafb240ae3e3291f81b8de00e9c511d3dd41d687a227dd4b529be591/coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7", size = 248004 }, - { url = "https://files.pythonhosted.org/packages/83/93/967d9662b1eb8c7c46917dcc7e4c1875724ac3e73c3cb78e86d7a0ac719d/coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5", size = 247828 }, - { url = "https://files.pythonhosted.org/packages/4c/1c/5077493c03215701e212767e470b794548d817dfc6247a4718832cc71fac/coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094", size = 249588 }, - { url = "https://files.pythonhosted.org/packages/7f/a5/77f64de461016e7da3e05d7d07975c89756fe672753e4cf74417fc9b9052/coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c", size = 219223 }, - { url = "https://files.pythonhosted.org/packages/ed/1c/ec51a3c1a59d225b44bdd3a4d463135b3159a535c2686fac965b698524f4/coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2", size = 220033 }, - { url = "https://files.pythonhosted.org/packages/01/ec/e0ce39746ed558564c16f2cc25fa95ce6fc9fa8bfb3b9e62855d4386b886/coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944", size = 218661 }, - { url = "https://files.pythonhosted.org/packages/46/cb/483f130bc56cbbad2638248915d97b185374d58b19e3cc3107359715949f/coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428", size = 217389 }, - { url = "https://files.pythonhosted.org/packages/cb/ae/81f89bae3afef75553cf10e62feb57551535d16fd5859b9ee5a2a97ddd27/coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a", size = 217742 }, - { url = "https://files.pythonhosted.org/packages/db/6e/a0fb897041949888191a49c36afd5c6f5d9f5fd757e0b0cd99ec198a324b/coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655", size = 259049 }, - { url = "https://files.pythonhosted.org/packages/d9/b6/d13acc67eb402d91eb94b9bd60593411799aed09ce176ee8d8c0e39c94ca/coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7", size = 261113 }, - { url = "https://files.pythonhosted.org/packages/ea/07/a6868893c48191d60406df4356aa7f0f74e6de34ef1f03af0d49183e0fa1/coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d", size = 263546 }, - { url = "https://files.pythonhosted.org/packages/24/e5/28598f70b2c1098332bac47925806353b3313511d984841111e6e760c016/coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f", size = 258260 }, - { url = "https://files.pythonhosted.org/packages/0e/58/58e2d9e6455a4ed746a480c4b9cf96dc3cb2a6b8f3efbee5efd33ae24b06/coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0", size = 261121 }, - { url = "https://files.pythonhosted.org/packages/17/57/38803eefb9b0409934cbc5a14e3978f0c85cb251d2b6f6a369067a7105a0/coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739", size = 258736 }, - { url = "https://files.pythonhosted.org/packages/a8/f3/f94683167156e93677b3442be1d4ca70cb33718df32a2eea44a5898f04f6/coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71", size = 257625 }, - { url = "https://files.pythonhosted.org/packages/87/ed/42d0bf1bc6bfa7d65f52299a31daaa866b4c11000855d753857fe78260ac/coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76", size = 259827 }, - { url = "https://files.pythonhosted.org/packages/d3/76/5682719f5d5fbedb0c624c9851ef847407cae23362deb941f185f489c54e/coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c", size = 219897 }, - { url = "https://files.pythonhosted.org/packages/10/e0/1da511d0ac3d39e6676fa6cc5ec35320bbf1cebb9b24e9ee7548ee4e931a/coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac", size = 220959 }, - { url = "https://files.pythonhosted.org/packages/e5/9d/e255da6a04e9ec5f7b633c54c0fdfa221a9e03550b67a9c83217de12e96c/coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc", size = 219234 }, - { url = "https://files.pythonhosted.org/packages/84/d6/634ec396e45aded1772dccf6c236e3e7c9604bc47b816e928f32ce7987d1/coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c", size = 216746 }, - { url = "https://files.pythonhosted.org/packages/28/76/1079547f9d46f9c7c7d0dad35b6873c98bc5aa721eeabceafabd722cd5e7/coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203", size = 217077 }, - { url = "https://files.pythonhosted.org/packages/2d/71/6ad80d6ae0d7cb743b9a98df8bb88b1ff3dc54491508a4a97549c2b83400/coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240", size = 248122 }, - { url = "https://files.pythonhosted.org/packages/20/1d/784b87270784b0b88e4beec9d028e8d58f73ae248032579c63ad2ac6f69a/coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83", size = 250638 }, - { url = "https://files.pythonhosted.org/packages/f5/26/b6dd31e23e004e9de84d1a8672cd3d73e50f5dae65dbd0f03fa2cdde6100/coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902", size = 251972 }, - { url = "https://files.pythonhosted.org/packages/c9/ef/f9c64d76faac56b82daa036b34d4fe9ab55eb37f22062e68e9470583e688/coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428", size = 248147 }, - { url = "https://files.pythonhosted.org/packages/b6/eb/5b666f90a8f8053bd264a1ce693d2edef2368e518afe70680070fca13ecd/coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75", size = 249995 }, - { url = "https://files.pythonhosted.org/packages/eb/7b/871e991ffb5d067f8e67ffb635dabba65b231d6e0eb724a4a558f4a702a5/coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704", size = 247948 }, - { url = "https://files.pythonhosted.org/packages/0a/8b/ce454f0af9609431b06dbe5485fc9d1c35ddc387e32ae8e374f49005748b/coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b", size = 247770 }, - { url = "https://files.pythonhosted.org/packages/61/8f/79002cb58a61dfbd2085de7d0a46311ef2476823e7938db80284cedd2428/coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131", size = 249431 }, - { url = "https://files.pythonhosted.org/packages/58/cc/d06685dae97468ed22999440f2f2f5060940ab0e7952a7295f236d98cce7/coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a", size = 219508 }, - { url = "https://files.pythonhosted.org/packages/5f/ed/770cd07706a3598c545f62d75adf2e5bd3791bffccdcf708ec383ad42559/coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86", size = 220325 }, - { url = "https://files.pythonhosted.org/packages/ee/ac/6a1c507899b6fb1b9a56069954365f655956bcc648e150ce64c2b0ecbed8/coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e", size = 218899 }, - { url = "https://files.pythonhosted.org/packages/9a/58/142cd838d960cd740654d094f7b0300d7b81534bb7304437d2439fb685fb/coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df", size = 217471 }, - { url = "https://files.pythonhosted.org/packages/bc/2c/2f44d39eb33e41ab3aba80571daad32e0f67076afcf27cb443f9e5b5a3ee/coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001", size = 217742 }, - { url = "https://files.pythonhosted.org/packages/32/76/8ebc66c3c699f4de3174a43424c34c086323cd93c4930ab0f835731c443a/coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de", size = 259120 }, - { url = "https://files.pythonhosted.org/packages/19/89/78a3302b9595f331b86e4f12dfbd9252c8e93d97b8631500888f9a3a2af7/coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926", size = 261229 }, - { url = "https://files.pythonhosted.org/packages/07/59/1a9c0844dadef2a6efac07316d9781e6c5a3f3ea7e5e701411e99d619bfd/coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd", size = 263642 }, - { url = "https://files.pythonhosted.org/packages/37/86/66c15d190a8e82eee777793cabde730640f555db3c020a179625a2ad5320/coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac", size = 258193 }, - { url = "https://files.pythonhosted.org/packages/c7/c7/4a4aeb25cb6f83c3ec4763e5f7cc78da1c6d4ef9e22128562204b7f39390/coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46", size = 261107 }, - { url = "https://files.pythonhosted.org/packages/ed/91/b986b5035f23cf0272446298967ecdd2c3c0105ee31f66f7e6b6948fd7f8/coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64", size = 258717 }, - { url = "https://files.pythonhosted.org/packages/f0/c7/6c084997f5a04d050c513545d3344bfa17bd3b67f143f388b5757d762b0b/coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f", size = 257541 }, - { url = "https://files.pythonhosted.org/packages/3b/c5/38e642917e406930cb67941210a366ccffa767365c8f8d9ec0f465a8b218/coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820", size = 259872 }, - { url = "https://files.pythonhosted.org/packages/b7/67/5e812979d20c167f81dbf9374048e0193ebe64c59a3d93d7d947b07865fa/coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237", size = 220289 }, - { url = "https://files.pythonhosted.org/packages/24/3a/b72573802672b680703e0df071faadfab7dcd4d659aaaffc4626bc8bbde8/coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9", size = 221398 }, - { url = "https://files.pythonhosted.org/packages/f8/4e/649628f28d38bad81e4e8eb3f78759d20ac173e3c456ac629123815feb40/coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd", size = 219435 }, - { url = "https://files.pythonhosted.org/packages/19/8f/92bdd27b067204b99f396a1414d6342122f3e2663459baf787108a6b8b84/coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe", size = 208478 }, -] - -[package.optional-dependencies] -toml = [ - { name = "tomli", marker = "python_full_version >= '3.10' and python_full_version <= '3.11'" }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674 }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, -] - -[[package]] -name = "mypy-extensions" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, -] - -[[package]] -name = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, -] - -[[package]] -name = "pathspec" -version = "0.12.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191 }, -] - -[[package]] -name = "platformdirs" -version = "4.3.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, -] - -[[package]] -name = "platformdirs" -version = "4.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654 }, -] - -[[package]] -name = "platformdirs" -version = "4.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651 }, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, -] - -[[package]] -name = "pyagfs" -version = "0.1.5" -source = { editable = "." } -dependencies = [ - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] - -[package.optional-dependencies] -dev = [ - { name = "black", version = "24.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "black", version = "25.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pytest-cov", version = "5.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pytest-cov", version = "7.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.270" }, -] -provides-extras = ["dev"] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, -] - -[[package]] -name = "pytest" -version = "8.3.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "packaging", marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "tomli", marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version == '3.9.*' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.9.*'" }, - { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "packaging", marker = "python_full_version == '3.9.*'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pygments", marker = "python_full_version == '3.9.*'" }, - { name = "tomli", marker = "python_full_version == '3.9.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, -] - -[[package]] -name = "pytest" -version = "9.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, - { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "packaging", marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments", marker = "python_full_version >= '3.10'" }, - { name = "tomli", marker = "python_full_version == '3.10.*'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/da/1d/eb34f286b164c5e431a810a38697409cca1112cee04b287bb56ac486730b/pytest-9.0.0.tar.gz", hash = "sha256:8f44522eafe4137b0f35c9ce3072931a788a21ee40a2ed279e817d3cc16ed21e", size = 1562764 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/99/cafef234114a3b6d9f3aaed0723b437c40c57bdb7b3e4c3a575bc4890052/pytest-9.0.0-py3-none-any.whl", hash = "sha256:e5ccdf10b0bac554970ee88fc1a4ad0ee5d221f8ef22321f9b7e4584e19d7f96", size = 373364 }, -] - -[[package]] -name = "pytest-cov" -version = "5.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "coverage", version = "7.6.1", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version < '3.9'" }, - { name = "pytest", version = "8.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990 }, -] - -[[package]] -name = "pytest-cov" -version = "7.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "coverage", version = "7.10.7", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version == '3.9.*'" }, - { name = "coverage", version = "7.11.3", source = { registry = "https://pypi.org/simple" }, extra = ["toml"], marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "pytest", version = "9.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424 }, -] - -[[package]] -name = "pytokens" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195 }, -] - -[[package]] -name = "requests" -version = "2.32.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version < '3.9'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.9'" }, - { name = "idna", marker = "python_full_version < '3.9'" }, - { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.9'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.9'" }, - { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, -] - -[[package]] -name = "ruff" -version = "0.14.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781 }, - { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765 }, - { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120 }, - { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877 }, - { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538 }, - { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942 }, - { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306 }, - { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427 }, - { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488 }, - { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908 }, - { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803 }, - { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654 }, - { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520 }, - { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431 }, - { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394 }, - { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429 }, - { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380 }, - { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065 }, -] - -[[package]] -name = "tomli" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236 }, - { url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084 }, - { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832 }, - { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052 }, - { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555 }, - { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128 }, - { url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445 }, - { url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165 }, - { url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891 }, - { url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796 }, - { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121 }, - { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070 }, - { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859 }, - { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296 }, - { url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124 }, - { url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698 }, - { url = "https://files.pythonhosted.org/packages/89/48/06ee6eabe4fdd9ecd48bf488f4ac783844fd777f547b8d1b61c11939974e/tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b", size = 154819 }, - { url = "https://files.pythonhosted.org/packages/f1/01/88793757d54d8937015c75dcdfb673c65471945f6be98e6a0410fba167ed/tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae", size = 148766 }, - { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771 }, - { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586 }, - { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792 }, - { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909 }, - { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946 }, - { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705 }, - { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244 }, - { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637 }, - { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925 }, - { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045 }, - { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835 }, - { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109 }, - { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930 }, - { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964 }, - { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065 }, - { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088 }, - { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193 }, - { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488 }, - { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669 }, - { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709 }, - { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563 }, - { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756 }, - { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408 }, -] - -[[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, -] diff --git a/third_party/agfs/agfs-server/.dockerignore b/third_party/agfs/agfs-server/.dockerignore deleted file mode 100644 index 39a85e023..000000000 --- a/third_party/agfs/agfs-server/.dockerignore +++ /dev/null @@ -1,39 +0,0 @@ -# Build artifacts -build/ -*.exe -*.dll -*.so -*.dylib - -# Test files -*.test -*.out -coverage.out -coverage.html - -# IDE files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Git -.git/ -.gitignore - -# Documentation -README.md -*.md -examples/ - -# Docker -Dockerfile -.dockerignore - -# Config files (runtime) -config.yaml - -# OS files -.DS_Store -Thumbs.db diff --git a/third_party/agfs/agfs-server/.gitignore b/third_party/agfs/agfs-server/.gitignore deleted file mode 100644 index 910d23ffe..000000000 --- a/third_party/agfs/agfs-server/.gitignore +++ /dev/null @@ -1,204 +0,0 @@ -# ============================================================================ -# AGFS Server .gitignore -# ============================================================================ - -# ============================================================================ -# Production Configuration Files -# ============================================================================ -*.prod.yaml -*.prod.yml -*.production.yaml -*.production.yml - -# Keep example config -!config.example.yaml -!config.example.yml - -# ============================================================================ -# Build Artifacts - Go -# ============================================================================ -# Binary files -agfs-server -agfs-server.exe -*.exe -*.exe~ - -# Go build cache -*.test -*.out -/build/ -/dist/ - -# ============================================================================ -# Build Artifacts - Rust -# ============================================================================ -# Cargo build directory -target/ -**/target/ - -# Cargo.lock in libraries (keep in binaries) -# **/Cargo.lock - -# ============================================================================ -# Build Artifacts - C/C++ -# ============================================================================ -*.o -*.a -*.so -*.so.* -*.dylib -*.dll -*.lib -*.obj - -# WASM -*.wasm - -# ============================================================================ -# Temporary and Cache Files -# ============================================================================ -# OS generated files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db -Thumbs.db - -# Temporary files -*.tmp -*.temp -*.swp -*.swo -*~ -.*.swp - -# SQLite databases (often used in development) -*.db -*.sqlite -*.sqlite3 -*.db-shm -*.db-wal - -# Keep example databases if any -!*.example.db -!*.example.sqlite - -# ============================================================================ -# Logs and Runtime -# ============================================================================ -*.log -logs/ -log/ -*.pid - -# ============================================================================ -# IDE and Editor Files -# ============================================================================ -# Visual Studio Code -.vscode/ -*.code-workspace - -# JetBrains IDEs (GoLand, IntelliJ, etc.) -.idea/ -*.iml -*.iws -*.ipr - -# Vim -*.swp -*.swo -Session.vim -.netrwhist - -# Emacs -*~ -\#*\# -/.emacs.desktop -/.emacs.desktop.lock -*.elc - -# Sublime Text -*.sublime-project -*.sublime-workspace - -# ============================================================================ -# Testing and Coverage -# ============================================================================ -coverage.txt -coverage.out -*.coverprofile -.coverage -htmlcov/ - -# Test binaries -*.test - -# ============================================================================ -# Dependencies and Vendoring -# ============================================================================ -# Go vendor (if not using modules) -/vendor/ - -# Node modules (if any frontend tools) -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# ============================================================================ -# Documentation Build -# ============================================================================ -/docs/_build/ -/docs/.doctrees/ - -# ============================================================================ -# Cloud and Deployment -# ============================================================================ -# Kubernetes secrets -secrets.yaml -secrets.yml - -# Terraform -*.tfstate -*.tfstate.* -.terraform/ - -# Docker -docker-compose.override.yml - -# Environment files with secrets -.env -.env.local -.env.*.local - -# ============================================================================ -# Security and Credentials -# ============================================================================ -# Private keys -*.pem -*.key -*.crt -*.p12 -*.pfx - -# Credentials -credentials.json -service-account.json - -# ============================================================================ -# Miscellaneous -# ============================================================================ -# Downloaded plugins -plugins/*.so -plugins/*.dylib -plugins/*.dll -plugins/*.wasm - -# Temporary plugin cache -.plugin-cache/ - -# Go work file (for multi-module workspaces) -go.work -go.work.sum diff --git a/third_party/agfs/agfs-server/Dockerfile b/third_party/agfs/agfs-server/Dockerfile deleted file mode 100644 index 4ed7ddf56..000000000 --- a/third_party/agfs/agfs-server/Dockerfile +++ /dev/null @@ -1,60 +0,0 @@ -# Build stage -FROM golang:1.25-alpine AS builder - -# Install build dependencies -RUN apk add --no-cache git make gcc musl-dev sqlite-dev python3 py3-pip npm \ - autoconf automake libtool python3-dev jq-dev - -# Install uv for Python package management -RUN pip3 install --break-system-packages uv - -# Set working directory -WORKDIR /build - -# Copy SDK first -COPY agfs-sdk /agfs-sdk - -# Copy go mod files -COPY agfs-server/go.mod agfs-server/go.sum ./ -RUN go mod download - -# Copy source code -COPY agfs-server . - -# Build the application -RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-w -s" -o agfs-server cmd/server/main.go - -# Build agfs-shell -WORKDIR /build-shell -COPY agfs-shell . -RUN python3 build.py - -# Runtime stage -FROM alpine:latest - -# Install runtime dependencies (including Python 3 for agfs-shell) -RUN apk add --no-cache ca-certificates sqlite-libs python3 jq oniguruma - -# Create app directory -WORKDIR /app - -# Copy binary from builder -COPY --from=builder /build/agfs-server . - -# Copy configuration files to root -COPY --from=builder /build/config.example.yaml /config.example.yaml -COPY --from=builder /build/config.example.yaml /config.yaml - -# Copy agfs-shell portable distribution -COPY --from=builder /build-shell/dist/agfs-shell-portable /usr/local/agfs-shell -RUN ln -s /usr/local/agfs-shell/agfs-shell /usr/local/bin/agfs-shell - -# Create directory for localfs mount -RUN mkdir -p /data - -# Expose default port -EXPOSE 8080 - -# Run the server -ENTRYPOINT ["./agfs-server"] -CMD ["-c", "/config.yaml"] diff --git a/third_party/agfs/agfs-server/Makefile b/third_party/agfs/agfs-server/Makefile deleted file mode 100644 index a769aeb79..000000000 --- a/third_party/agfs/agfs-server/Makefile +++ /dev/null @@ -1,114 +0,0 @@ -.PHONY: all build run test clean install help lint deps - -# Variables -BINARY_NAME=agfs-server -BUILD_DIR=build -CMD_DIR=cmd/server -GO=go -GOFLAGS=-v -ADDR?=:8080 - -# OS detection -ifeq ($(OS),Windows_NT) - PLATFORM := windows -else - UNAME_S := $(shell uname -s) - ifeq ($(UNAME_S),Linux) - PLATFORM := linux - endif - ifeq ($(UNAME_S),Darwin) - PLATFORM := darwin - endif -endif - -# Build information -VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") -BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S') -GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") - -# LDFLAGS for build information -LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)" - -all: build ## Build the project (default target) - -build: ## Build the server binary - @echo "Building $(BINARY_NAME)..." - @mkdir -p $(BUILD_DIR) - $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR)/main.go - @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)" - -build-lib: ## Build AGFS binding library - @echo "Building binding library for $(PLATFORM)..." - @mkdir -p $(BUILD_DIR) -ifeq ($(PLATFORM),darwin) - CGO_ENABLED=1 $(GO) build -buildmode=c-shared -o $(BUILD_DIR)/libagfsbinding.dylib cmd/pybinding/main.go -else ifeq ($(PLATFORM),linux) - CGO_ENABLED=1 $(GO) build -buildmode=c-shared -o $(BUILD_DIR)/libagfsbinding.so cmd/pybinding/main.go -else ifeq ($(PLATFORM),windows) - CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ AR=x86_64-w64-mingw32-ar GOOS=windows GOARCH=amd64 CGO_ENABLED=1 $(GO) build -buildmode=c-shared -o $(BUILD_DIR)/libagfsbinding.dll cmd/pybinding/main.go -else - @echo "Unsupported OS: $(PLATFORM)" && exit 1 -endif - @echo "Build complete in $(BUILD_DIR)" - -run: build - @echo "Starting $(BINARY_NAME) on $(ADDR)..." - ./$(BUILD_DIR)/$(BINARY_NAME) -addr $(ADDR) - -dev: ## Run the server in development mode (without building binary) - @echo "Running server in development mode on $(ADDR)..." - $(GO) run $(CMD_DIR)/main.go -addr $(ADDR) - -install: build ## Install the binary to $GOPATH/bin - @echo "Installing $(BINARY_NAME) to $(GOPATH)/bin..." - $(GO) install $(LDFLAGS) $(CMD_DIR)/main.go - @echo "Installed successfully" - -test: ## Run all tests - @echo "Running tests..." - $(GO) test -v ./... - -lint: ## Run golangci-lint (requires golangci-lint to be installed) - @echo "Running golangci-lint..." - @if command -v golangci-lint >/dev/null 2>&1; then \ - golangci-lint run ./...; \ - else \ - echo "golangci-lint not installed. Install it from https://golangci-lint.run/usage/install/"; \ - exit 1; \ - fi - -deps: ## Download dependencies - $(GO) mod download - $(GO) mod tidy - -deps-update: ## Update dependencies - $(GO) get -u ./... - $(GO) mod tidy - -clean: ## Clean build artifacts - @echo "Cleaning..." - @rm -rf $(BUILD_DIR) - @rm -f coverage.out coverage.html - @rm -f $(BINARY_NAME) - @echo "Clean complete" - -docker-build: ## Build Docker image - @echo "Building Docker image..." - docker build -t agfs-server:$(VERSION) . - -docker-run: ## Run Docker container - @echo "Running Docker container..." - docker run -p 8080:8080 agfs-server:$(VERSION) - -release: clean test build ## Run tests and build release binary - @echo "Creating release build..." - @mkdir -p $(BUILD_DIR)/release - GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/release/$(BINARY_NAME)-linux-amd64 $(CMD_DIR)/main.go - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/release/$(BINARY_NAME)-linux-arm64 $(CMD_DIR)/main.go - GOOS=darwin GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/release/$(BINARY_NAME)-darwin-amd64 $(CMD_DIR)/main.go - GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/release/$(BINARY_NAME)-darwin-arm64 $(CMD_DIR)/main.go - GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/release/$(BINARY_NAME)-windows-amd64.exe $(CMD_DIR)/main.go - @echo "Release builds complete in $(BUILD_DIR)/release/" - -help: ## Display this help screen - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/third_party/agfs/agfs-server/README.md b/third_party/agfs/agfs-server/README.md deleted file mode 100644 index 749ab4339..000000000 --- a/third_party/agfs/agfs-server/README.md +++ /dev/null @@ -1,270 +0,0 @@ -# AGFS Server - -A Plugin-based RESTful file system server with a powerful plugin architecture that exposes services as virtual file systems. Access queues, key-value stores, databases, and more through simple file operations. - -## Features - -- **Plugin Architecture**: Mount multiple filesystems and services at different paths. -- **External Plugin Support**: Load plugins from dynamic libraries (.so/.dylib/.dll) or WebAssembly modules without recompiling. -- **Unified API**: Single HTTP API for all file operations across all plugins. -- **Dynamic Mounting**: Add/remove plugins at runtime without restarting. -- **Configuration-based**: YAML configuration supports both single and multi-instance plugins. -- **Built-in Plugins**: Includes various useful plugins like QueueFS, KVFS, S3FS, SQLFS, and more. -- **Zero Cgo for Native Plugins**: Uses purego for FFI, eliminating the need for a C compiler for Go code. - -## Quick Start - -### Using Docker (Recommended) - -The easiest way to get started is using Docker: - -1. **Pull the image**: - ```bash - docker pull c4pt0r/agfs-server:latest - ``` - -2. **Run the server with port mapping**: - ```bash - # Basic run - expose port 8080 to host - docker run -d -p 8080:8080 --name agfs-server c4pt0r/agfs-server:latest - - # With custom port mapping (host:container) - docker run -d -p 9000:8080 --name agfs-server c4pt0r/agfs-server:latest - - # With data persistence (mount /data directory) - docker run -d -p 8080:8080 -v $(pwd)/data:/data --name agfs-server c4pt0r/agfs-server:latest - - # With custom configuration - docker run -d -p 8080:8080 -v $(pwd)/config.yaml:/config.yaml --name agfs-server c4pt0r/agfs-server:latest - ``` - -3. **Using agfs-shell inside the container**: - - The Docker image includes `agfs-shell` for convenient file system operations. - - ```bash - # Enter the container with interactive shell - docker exec -it agfs-server /bin/sh - - # Inside the container, use agfs-shell - agfs-shell - - # Or run agfs-shell commands directly - docker exec -it agfs-server agfs-shell -c "ls /" - docker exec -it agfs-server agfs-shell -c "cat /memfs/hello.txt" - ``` - -4. **Verify the server is running**: - ```bash - curl http://localhost:8080/api/v1/health - ``` - -5. **Stop and remove the container**: - ```bash - docker stop agfs-server - docker rm agfs-server - ``` - -### Build and Run from Source - -1. **Build the server**: - ```bash - make build - ``` - -2. **Run with default configuration** (port 8080): - ```bash - ./build/agfs-server - ``` - -3. **Run with custom configuration**: - ```bash - ./build/agfs-server -c config.yaml - ``` - -4. **Run on a different port**: - ```bash - ./build/agfs-server -addr :9000 - ``` - -### Basic Usage - -You can interact with the server using standard HTTP clients like `curl` or the `agfs-shell` (if available). - -**List root directory**: -```bash -curl "http://localhost:8080/api/v1/directories?path=/" -``` - -**Write to a file (MemFS example)**: -```bash -curl -X PUT "http://localhost:8080/api/v1/files?path=/memfs/hello.txt" -d "Hello, AGFS!" -``` - -**Read a file**: -```bash -curl "http://localhost:8080/api/v1/files?path=/memfs/hello.txt" -``` - -## Configuration - -The server is configured using a YAML file (default: `config.yaml`). - -### Structure - -```yaml -server: - address: ":8080" - log_level: info # debug, info, warn, error - -# External plugins configuration -external_plugins: - enabled: true - plugin_dir: "./plugins" # Auto-load plugins from this directory - auto_load: true - plugin_paths: # Specific plugins to load - - "./examples/hellofs-c/hellofs-c.dylib" - -plugins: - # Single instance configuration - memfs: - enabled: true - path: /memfs - config: - init_dirs: - - /tmp - - # Multi-instance configuration - sqlfs: - - name: local - enabled: true - path: /sqlfs - config: - backend: sqlite - db_path: sqlfs.db - - - name: production - enabled: true - path: /sqlfs_prod - config: - backend: tidb - dsn: "user:pass@tcp(host:4000)/db" -``` - -See `config.example.yaml` for a complete reference. - -## Built-in Plugins - -AGFS Server comes with a rich set of built-in plugins. - -### Storage Plugins - -- **MemFS**: In-memory file system. Fast, non-persistent storage ideal for temporary data and caching. -- **LocalFS**: Mounts local directories into the AGFS namespace. Allows direct access to the host file system. -- **S3FS**: Exposes Amazon S3 buckets as a file system. Supports reading, writing, and listing objects. -- **SQLFS**: Database-backed file system. Stores files and metadata in SQL databases (SQLite, TiDB, MySQL). - -### Application Plugins - -- **QueueFS**: Exposes message queues as directories. - - `enqueue`: Write to add a message. - - `dequeue`: Read to pop a message. - - `peek`: Read to view the next message. - - `size`: Read to get queue size. - - Supports Memory, SQLite, and TiDB backends. -- **KVFS**: Key-Value store where keys are files and values are file content. -- **StreamFS**: Supports streaming data with multiple concurrent readers (Ring Buffer). Ideal for live video or data feeds. -- **HeartbeatFS**: Heartbeat monitoring service. - - Create items with `mkdir`. - - Send heartbeats by touching `keepalive`. - - Monitor status via `ctl`. - - Items expire automatically if no heartbeat is received within the timeout. - -### Network & Utility Plugins - -- **ProxyFS**: Federation plugin. Proxies requests to remote AGFS servers, allowing you to mount remote instances locally. -- **HTTPFS** (HTTAGFS): Serves any AGFS path via HTTP. Browsable directory listings and file downloads. Can be mounted dynamically to temporarily share files. -- **ServerInfoFS**: Exposes server metadata (version, uptime, stats) as files. -- **HelloFS**: A simple example plugin for learning and testing. - -## Dynamic Plugin Management - -You can mount, unmount, and manage plugins at runtime using the API. - -**Mount a plugin**: -```bash -curl -X POST http://localhost:8080/api/v1/mount \ - -H "Content-Type: application/json" \ - -d '{ - "fstype": "memfs", - "path": "/temp_ram", - "config": {} - }' -``` - -**Unmount a plugin**: -```bash -curl -X POST http://localhost:8080/api/v1/unmount \ - -H "Content-Type: application/json" \ - -d '{"path": "/temp_ram"}' -``` - -**List mounted plugins**: -```bash -curl http://localhost:8080/api/v1/mounts -``` - -## External Plugins - -AGFS Server supports loading external plugins compiled as shared libraries (`.so`, `.dylib`, `.dll`) or WebAssembly (`.wasm`) modules. - -### Native Plugins (C/C++/Rust) -Native plugins must export a C-compatible API. They offer maximum performance and full system access. -See `examples/hellofs-c` or `examples/hellofs-rust` for implementation details. - -### WebAssembly Plugins -WASM plugins run in a sandboxed environment (WasmTime). They are cross-platform and secure. -See `examples/hellofs-wasm` for implementation details. - -### Loading External Plugins -```bash -curl -X POST http://localhost:8080/api/v1/plugins/load \ - -d '{"library_path": "./my-plugin.so"}' -``` - -## API Reference - -All API endpoints are prefixed with `/api/v1/`. - -| Resource | Method | Endpoint | Description | -|----------|--------|----------|-------------| -| **Files** | `GET` | `/files` | Read file content | -| | `PUT` | `/files` | Write file content | -| | `POST` | `/files` | Create empty file | -| | `DELETE` | `/files` | Delete file | -| | `GET` | `/stat` | Get file metadata | -| **Directories** | `GET` | `/directories` | List directory contents | -| | `POST` | `/directories` | Create directory | -| **Management** | `GET` | `/mounts` | List active mounts | -| | `POST` | `/mount` | Mount a plugin | -| | `POST` | `/unmount` | Unmount a plugin | -| | `GET` | `/plugins` | List loaded external plugins | -| | `POST` | `/plugins/load` | Load an external plugin | -| | `POST` | `/plugins/unload` | Unload an external plugin | -| **System** | `GET` | `/health` | Server health check | - -## Development - -### Requirements -- Go 1.21+ -- Make - -### Commands -- `make build`: Build the server binary. -- `make test`: Run tests. -- `make dev`: Run the server in development mode. -- `make install`: Install the binary to `$GOPATH/bin`. - -## License - -Apache License 2.0 \ No newline at end of file diff --git a/third_party/agfs/agfs-server/agfs-server.service b/third_party/agfs/agfs-server/agfs-server.service deleted file mode 100644 index a57136b00..000000000 --- a/third_party/agfs/agfs-server/agfs-server.service +++ /dev/null @@ -1,21 +0,0 @@ -[Unit] -Description=AGFS Server - A General File System -Documentation=https://github.com/c4pt0r/agfs -After=network.target - -[Service] -Type=simple -User=%USER% -Group=%GROUP% -ExecStart=%INSTALL_DIR%/agfs-server -c /etc/agfs.yaml -Restart=on-failure -RestartSec=5s -StandardOutput=journal -StandardError=journal - -# Security settings -NoNewPrivileges=true -PrivateTmp=true - -[Install] -WantedBy=multi-user.target diff --git a/third_party/agfs/agfs-server/api.md b/third_party/agfs/agfs-server/api.md deleted file mode 100644 index 04fcf8f2b..000000000 --- a/third_party/agfs/agfs-server/api.md +++ /dev/null @@ -1,764 +0,0 @@ -# AGFS Server API Reference - -This document provides a comprehensive reference for the AGFS Server RESTful API. All endpoints are prefixed with `/api/v1`. - -## Response Formats - -### Success Response -Most successful write/modification operations return a JSON object with a message: -```json -{ - "message": "operation successful" -} -``` - -### Error Response -Errors are returned with an appropriate HTTP status code and a JSON object: -```json -{ - "error": "error message description" -} -``` - -### File Info Object -Used in `stat` and directory listing responses: -```json -{ - "name": "filename", - "size": 1024, - "mode": 420, // File mode (decimal) - "modTime": "2023-10-27T10:00:00Z", - "isDir": false, - "meta": { // Optional metadata - "name": "plugin_name", - "type": "file_type" - } -} -``` - ---- - -## File Operations - -### Read File -Read content from a file. - -**Endpoint:** `GET /api/v1/files` - -**Query Parameters:** -- `path` (required): Absolute path to the file. -- `offset` (optional): Byte offset to start reading from. -- `size` (optional): Number of bytes to read. Defaults to reading until EOF. -- `stream` (optional): Set to `true` for streaming response (Chunked Transfer Encoding). - -**Response:** -- Binary file content (`application/octet-stream`). - -**Example:** -```bash -curl "http://localhost:8080/api/v1/files?path=/memfs/data.txt" -``` - -### Write File -Write content to a file. Supports various write modes through flags. - -**Endpoint:** `PUT /api/v1/files` - -**Query Parameters:** -- `path` (required): Absolute path to the file. -- `offset` (optional): Byte offset for write position. Use `-1` for default behavior (typically truncate or append based on flags). -- `flags` (optional): Comma-separated write flags to control behavior. - -**Write Flags:** -- `append` - Append data to end of file -- `create` - Create file if it doesn't exist -- `exclusive` - Fail if file already exists (with `create`) -- `truncate` - Truncate file before writing -- `sync` - Synchronous write (fsync after write) - -Default behavior (no flags): Creates file if needed and truncates existing content. - -**Body:** Raw file content. - -**Response:** -```json -{ - "message": "write successful", - "written": 1024 -} -``` - -**Examples:** -```bash -# Overwrite file (default behavior) -curl -X PUT "http://localhost:8080/api/v1/files?path=/memfs/data.txt" -d "Hello World" - -# Append to file -curl -X PUT "http://localhost:8080/api/v1/files?path=/memfs/data.txt&flags=append" -d "More content" - -# Write at specific offset (pwrite-style) -curl -X PUT "http://localhost:8080/api/v1/files?path=/memfs/data.txt&offset=10" -d "inserted" - -# Create exclusively (fail if exists) -curl -X PUT "http://localhost:8080/api/v1/files?path=/memfs/new.txt&flags=create,exclusive" -d "content" -``` - -### Create Empty File -Create a new empty file. - -**Endpoint:** `POST /api/v1/files` - -**Query Parameters:** -- `path` (required): Absolute path to the file. - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/files?path=/memfs/empty.txt" -``` - -### Delete File -Delete a file or directory. - -**Endpoint:** `DELETE /api/v1/files` - -**Query Parameters:** -- `path` (required): Absolute path. -- `recursive` (optional): Set to `true` to delete directories recursively. - -**Example:** -```bash -curl -X DELETE "http://localhost:8080/api/v1/files?path=/memfs/data.txt" -``` - -### Touch File -Update a file's timestamp or create it if it doesn't exist. - -**Endpoint:** `POST /api/v1/touch` - -**Query Parameters:** -- `path` (required): Absolute path. - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/touch?path=/memfs/data.txt" -``` - -### Calculate Digest -Calculate the hash digest of a file. - -**Endpoint:** `POST /api/v1/digest` - -**Body:** -```json -{ - "algorithm": "xxh3", // or "md5" - "path": "/memfs/large_file.iso" -} -``` - -**Response:** -```json -{ - "algorithm": "xxh3", - "path": "/memfs/large_file.iso", - "digest": "a1b2c3d4e5f6..." -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/digest" \ - -H "Content-Type: application/json" \ - -d '{"algorithm": "xxh3", "path": "/memfs/large_file.iso"}' -``` - -### Grep / Search -Search for a regex pattern within files. - -**Endpoint:** `POST /api/v1/grep` - -**Body:** -```json -{ - "path": "/memfs/logs", - "pattern": "error|warning", - "recursive": true, - "case_insensitive": true, - "stream": false -} -``` - -**Response (Normal):** -```json -{ - "matches": [ - { - "file": "/memfs/logs/app.log", - "line": 42, - "content": "ERROR: Connection failed" - } - ], - "count": 1 -} -``` - -**Response (Stream):** -Returns NDJSON (Newline Delimited JSON) stream of matches. - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/grep" \ - -H "Content-Type: application/json" \ - -d '{"path": "/memfs/logs", "pattern": "error|warning", "recursive": true, "case_insensitive": true}' -``` - ---- - -## Directory Operations - -### List Directory -Get a list of files in a directory. - -**Endpoint:** `GET /api/v1/directories` - -**Query Parameters:** -- `path` (optional): Absolute path. Defaults to `/`. - -**Response:** -```json -{ - "files": [ - { "name": "file1.txt", "size": 100, "isDir": false, ... }, - { "name": "dir1", "size": 0, "isDir": true, ... } - ] -} -``` - -**Example:** -```bash -curl "http://localhost:8080/api/v1/directories?path=/memfs" -``` - -### Create Directory -Create a new directory. - -**Endpoint:** `POST /api/v1/directories` - -**Query Parameters:** -- `path` (required): Absolute path. -- `mode` (optional): Octal mode (e.g., `0755`). - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/directories?path=/memfs/newdir" -``` - ---- - -## Metadata & Attributes - -### Get File Statistics -Get metadata for a file or directory. - -**Endpoint:** `GET /api/v1/stat` - -**Query Parameters:** -- `path` (required): Absolute path. - -**Response:** Returns a [File Info Object](#file-info-object). - -**Example:** -```bash -curl "http://localhost:8080/api/v1/stat?path=/memfs/data.txt" -``` - -### Rename -Rename or move a file/directory. - -**Endpoint:** `POST /api/v1/rename` - -**Query Parameters:** -- `path` (required): Current absolute path. - -**Body:** -```json -{ - "newPath": "/memfs/new_name.txt" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/rename?path=/memfs/old_name.txt" \ - -H "Content-Type: application/json" \ - -d '{"newPath": "/memfs/new_name.txt"}' -``` - -### Change Permissions (Chmod) -Change file mode bits. - -**Endpoint:** `POST /api/v1/chmod` - -**Query Parameters:** -- `path` (required): Absolute path. - -**Body:** -```json -{ - "mode": 420 // 0644 in decimal -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/chmod?path=/memfs/data.txt" \ - -H "Content-Type: application/json" \ - -d '{"mode": 420}' -``` - ---- - -## Plugin Management - -### List Mounts -List all currently mounted plugins. - -**Endpoint:** `GET /api/v1/mounts` - -**Response:** -```json -{ - "mounts": [ - { - "path": "/memfs", - "pluginName": "memfs", - "config": {} - } - ] -} -``` - -**Example:** -```bash -curl "http://localhost:8080/api/v1/mounts" -``` - -### Mount Plugin -Mount a new plugin instance. - -**Endpoint:** `POST /api/v1/mount` - -**Body:** -```json -{ - "fstype": "memfs", // Plugin type name - "path": "/my_memfs", // Mount path - "config": { // Plugin-specific configuration - "init_dirs": ["/tmp"] - } -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/mount" \ - -H "Content-Type: application/json" \ - -d '{"fstype": "memfs", "path": "/my_memfs", "config": {"init_dirs": ["/tmp"]}}' -``` - -### Unmount Plugin -Unmount a plugin. - -**Endpoint:** `POST /api/v1/unmount` - -**Body:** -```json -{ - "path": "/my_memfs" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/unmount" \ - -H "Content-Type: application/json" \ - -d '{"path": "/my_memfs"}' -``` - -### List Plugins -List all available (loaded) plugins, including external ones. - -**Endpoint:** `GET /api/v1/plugins` - -**Response:** -```json -{ - "plugins": [ - { - "name": "memfs", - "is_external": false, - "mounted_paths": [...] - }, - { - "name": "hellofs-c", - "is_external": true, - "library_path": "./plugins/hellofs.so" - } - ] -} -``` - -**Example:** -```bash -curl "http://localhost:8080/api/v1/plugins" -``` - -### Load External Plugin -Load a dynamic library plugin (.so/.dylib/.dll) or WASM plugin. - -**Endpoint:** `POST /api/v1/plugins/load` - -**Body:** -```json -{ - "library_path": "./plugins/myplugin.so" -} -``` -*Note: `library_path` can also be a URL (`http://...`) or an AGFS path (`agfs://...`) to load remote plugins.* - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/plugins/load" \ - -H "Content-Type: application/json" \ - -d '{"library_path": "./plugins/myplugin.so"}' -``` - -### Unload External Plugin -Unload a previously loaded external plugin. - -**Endpoint:** `POST /api/v1/plugins/unload` - -**Body:** -```json -{ - "library_path": "./plugins/myplugin.so" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/plugins/unload" \ - -H "Content-Type: application/json" \ - -d '{"library_path": "./plugins/myplugin.so"}' -``` - ---- - -## System - -### Health Check -Check server status and version. - -**Endpoint:** `GET /api/v1/health` - -**Response:** -```json -{ - "status": "healthy", - "version": "1.0.0", - "gitCommit": "abcdef", - "buildTime": "2023-..." -} -``` - -**Example:** -```bash -curl "http://localhost:8080/api/v1/health" -``` - ---- - -## Capabilities - -### Get Capabilities -Query the capabilities of a filesystem at a given path. Different filesystems support different features. - -**Endpoint:** `GET /api/v1/capabilities` - -**Query Parameters:** -- `path` (required): Absolute path to query capabilities for. - -**Response:** -```json -{ - "supportsRandomWrite": true, - "supportsTruncate": true, - "supportsSync": true, - "supportsTouch": true, - "supportsFileHandle": true, - "isAppendOnly": false, - "isReadDestructive": false, - "isObjectStore": false, - "isBroadcast": false, - "supportsStreamRead": false -} -``` - -**Capability Descriptions:** -- `supportsRandomWrite` - Supports writing at arbitrary offsets (pwrite) -- `supportsTruncate` - Supports truncating files to a specific size -- `supportsSync` - Supports fsync/flush operations -- `supportsTouch` - Supports updating file timestamps -- `supportsFileHandle` - Supports stateful file handle operations -- `isAppendOnly` - Only supports append operations (e.g., QueueFS enqueue) -- `isReadDestructive` - Read operations have side effects (e.g., QueueFS dequeue) -- `isObjectStore` - Object store semantics, no partial writes (e.g., S3FS) -- `isBroadcast` - Supports broadcast/fanout reads (e.g., StreamFS) -- `supportsStreamRead` - Supports streaming/chunked reads - -**Example:** -```bash -curl "http://localhost:8080/api/v1/capabilities?path=/memfs" -``` - ---- - -## File Handles (Stateful Operations) - -File handles provide stateful file access with seek support. This is useful for FUSE implementations and scenarios requiring multiple read/write operations on the same file. Handles use a lease mechanism for automatic cleanup. - -### Open File Handle -Open a file and get a handle for subsequent operations. - -**Endpoint:** `POST /api/v1/handles/open` - -**Query Parameters:** -- `path` (required): Absolute path to the file. -- `flags` (optional): Open flags (comma-separated): `read`, `write`, `readwrite`, `append`, `create`, `exclusive`, `truncate`. -- `mode` (optional): File mode for creation (octal, e.g., `0644`). -- `lease` (optional): Lease duration in seconds (default: 60, max: 300). - -**Response:** -```json -{ - "handle_id": "h_abc123", - "path": "/memfs/file.txt", - "flags": "readwrite", - "lease": 60, - "expires_at": "2024-01-01T12:01:00Z" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/handles/open?path=/memfs/file.txt&flags=readwrite,create&lease=120" -``` - -### Read via Handle -Read data from an open file handle. - -**Endpoint:** `GET /api/v1/handles/{handle_id}/read` - -**Query Parameters:** -- `offset` (optional): Position to read from. If not specified, reads from current position. -- `size` (optional): Number of bytes to read. - -**Response:** Binary data (`application/octet-stream`) - -**Note:** Each operation automatically renews the handle's lease. - -**Example:** -```bash -curl "http://localhost:8080/api/v1/handles/h_abc123/read?offset=0&size=1024" -``` - -### Write via Handle -Write data to an open file handle. - -**Endpoint:** `PUT /api/v1/handles/{handle_id}/write` - -**Query Parameters:** -- `offset` (optional): Position to write at. If not specified, writes at current position. - -**Body:** Raw binary data. - -**Response:** -```json -{ - "written": 1024 -} -``` - -**Example:** -```bash -curl -X PUT "http://localhost:8080/api/v1/handles/h_abc123/write?offset=0" -d "Hello World" -``` - -### Seek Handle -Change the current read/write position. - -**Endpoint:** `POST /api/v1/handles/{handle_id}/seek` - -**Query Parameters:** -- `offset` (required): Offset value. -- `whence` (optional): Reference point: `0` (start), `1` (current), `2` (end). Default: `0`. - -**Response:** -```json -{ - "position": 1024 -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/handles/h_abc123/seek?offset=100&whence=0" -``` - -### Sync Handle -Flush any buffered data to storage. - -**Endpoint:** `POST /api/v1/handles/{handle_id}/sync` - -**Response:** -```json -{ - "message": "synced" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/handles/h_abc123/sync" -``` - -### Renew Handle Lease -Explicitly renew the handle's lease (operations auto-renew). - -**Endpoint:** `POST /api/v1/handles/{handle_id}/renew` - -**Query Parameters:** -- `lease` (optional): New lease duration in seconds (max: 300). - -**Response:** -```json -{ - "expires_at": "2024-01-01T12:02:00Z" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/handles/h_abc123/renew?lease=120" -``` - -### Get Handle Info -Get information about an open handle. - -**Endpoint:** `GET /api/v1/handles/{handle_id}` - -**Response:** -```json -{ - "handle_id": "h_abc123", - "path": "/memfs/file.txt", - "flags": "readwrite", - "lease": 60, - "expires_at": "2024-01-01T12:01:00Z", - "created_at": "2024-01-01T12:00:00Z", - "last_access": "2024-01-01T12:00:30Z" -} -``` - -**Example:** -```bash -curl "http://localhost:8080/api/v1/handles/h_abc123" -``` - -### Close Handle -Close an open file handle. - -**Endpoint:** `DELETE /api/v1/handles/{handle_id}` - -**Response:** -```json -{ - "message": "closed" -} -``` - -**Example:** -```bash -curl -X DELETE "http://localhost:8080/api/v1/handles/h_abc123" -``` - -### List Handles -List all active file handles (admin/debugging). - -**Endpoint:** `GET /api/v1/handles` - -**Response:** -```json -{ - "handles": [ - { - "handle_id": "h_abc123", - "path": "/memfs/file.txt", - "flags": "readwrite", - "expires_at": "2024-01-01T12:01:00Z" - } - ], - "count": 1, - "max": 10000 -} -``` - -**Example:** -```bash -curl "http://localhost:8080/api/v1/handles" -``` - ---- - -## Advanced File Operations - -### Truncate File -Truncate a file to a specified size. - -**Endpoint:** `POST /api/v1/truncate` - -**Query Parameters:** -- `path` (required): Absolute path to the file. -- `size` (required): New file size in bytes. - -**Response:** -```json -{ - "message": "truncated" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/truncate?path=/memfs/file.txt&size=1024" -``` - -### Sync File -Synchronize file data to storage (fsync). - -**Endpoint:** `POST /api/v1/sync` - -**Query Parameters:** -- `path` (required): Absolute path to the file. - -**Response:** -```json -{ - "message": "synced" -} -``` - -**Example:** -```bash -curl -X POST "http://localhost:8080/api/v1/sync?path=/memfs/file.txt" -``` diff --git a/third_party/agfs/agfs-server/cmd/pybinding/main.go b/third_party/agfs/agfs-server/cmd/pybinding/main.go deleted file mode 100644 index ded736e7a..000000000 --- a/third_party/agfs/agfs-server/cmd/pybinding/main.go +++ /dev/null @@ -1,809 +0,0 @@ -package main - -/* -#include <stdlib.h> -#include <stdint.h> -#include <string.h> -*/ -import "C" - -import ( - "bufio" - "bytes" - "encoding/json" - "fmt" - "path" - "regexp" - "sync" - "time" - "unsafe" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/loader" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/gptfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/heartbeatfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/hellofs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/httpfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/kvfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/localfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/memfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/queuefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/s3fs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/serverinfofs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/sqlfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/streamfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/streamrotatefs" - log "github.com/sirupsen/logrus" -) - -var ( - globalFS *mountablefs.MountableFS - globalFSMu sync.RWMutex - handleMap = make(map[int64]filesystem.FileHandle) - handleMapMu sync.RWMutex - handleIDGen int64 - errorBuffer = make(map[int64]string) - errorBufferMu sync.RWMutex - errorIDGen int64 -) - -func init() { - poolConfig := api.PoolConfig{ - MaxInstances: 10, - } - globalFS = mountablefs.NewMountableFS(poolConfig) - registerBuiltinPlugins() -} - -func registerBuiltinPlugins() { - registerFunc := func(name string, factory func() plugin.ServicePlugin) { - globalFS.RegisterPluginFactory(name, factory) - } - - registerFunc("serverinfofs", func() plugin.ServicePlugin { return serverinfofs.NewServerInfoFSPlugin() }) - registerFunc("memfs", func() plugin.ServicePlugin { return memfs.NewMemFSPlugin() }) - registerFunc("queuefs", func() plugin.ServicePlugin { return queuefs.NewQueueFSPlugin() }) - registerFunc("kvfs", func() plugin.ServicePlugin { return kvfs.NewKVFSPlugin() }) - registerFunc("hellofs", func() plugin.ServicePlugin { return hellofs.NewHelloFSPlugin() }) - registerFunc("heartbeatfs", func() plugin.ServicePlugin { return heartbeatfs.NewHeartbeatFSPlugin() }) - registerFunc("httpfs", func() plugin.ServicePlugin { return httpfs.NewHTTPFSPlugin() }) - registerFunc("s3fs", func() plugin.ServicePlugin { return s3fs.NewS3FSPlugin() }) - registerFunc("streamfs", func() plugin.ServicePlugin { return streamfs.NewStreamFSPlugin() }) - registerFunc("streamrotatefs", func() plugin.ServicePlugin { return streamrotatefs.NewStreamRotateFSPlugin() }) - registerFunc("sqlfs", func() plugin.ServicePlugin { return sqlfs.NewSQLFSPlugin() }) - registerFunc("localfs", func() plugin.ServicePlugin { return localfs.NewLocalFSPlugin() }) - registerFunc("gptfs", func() plugin.ServicePlugin { return gptfs.NewGptfs() }) -} - -func storeError(err error) int64 { - if err == nil { - return 0 - } - errorBufferMu.Lock() - errorIDGen++ - id := errorIDGen - errorBuffer[id] = err.Error() - errorBufferMu.Unlock() - return id -} - -func getAndClearError(id int64) string { - if id == 0 { - return "" - } - errorBufferMu.Lock() - msg := errorBuffer[id] - delete(errorBuffer, id) - errorBufferMu.Unlock() - return msg -} - -func storeHandle(handle filesystem.FileHandle) int64 { - handleMapMu.Lock() - handleIDGen++ - id := handleIDGen - handleMap[id] = handle - handleMapMu.Unlock() - return id -} - -func getHandle(id int64) filesystem.FileHandle { - handleMapMu.RLock() - handle := handleMap[id] - handleMapMu.RUnlock() - return handle -} - -func removeHandle(id int64) { - handleMapMu.Lock() - delete(handleMap, id) - handleMapMu.Unlock() -} - -//export AGFS_NewClient -func AGFS_NewClient() int64 { - return 1 -} - -//export AGFS_FreeClient -func AGFS_FreeClient(clientID int64) { -} - -//export AGFS_GetLastError -func AGFS_GetLastError(errorID int64) *C.char { - msg := getAndClearError(errorID) - return C.CString(msg) -} - -//export AGFS_FreeString -func AGFS_FreeString(s *C.char) { - C.free(unsafe.Pointer(s)) -} - -//export AGFS_Health -func AGFS_Health(clientID int64) C.int { - return C.int(1) -} - -//export AGFS_GetCapabilities -func AGFS_GetCapabilities(clientID int64) *C.char { - caps := map[string]interface{}{ - "version": "binding", - "features": []string{"handlefs", "grep", "digest", "stream", "touch"}, - } - data, _ := json.Marshal(caps) - return C.CString(string(data)) -} - -//export AGFS_Ls -func AGFS_Ls(clientID int64, path *C.char) *C.char { - p := C.GoString(path) - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - files, err := fs.ReadDir(p) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - result := make([]map[string]interface{}, len(files)) - for i, f := range files { - result[i] = map[string]interface{}{ - "name": f.Name, - "size": f.Size, - "mode": f.Mode, - "modTime": f.ModTime.Format(time.RFC3339Nano), - "isDir": f.IsDir, - } - } - - data, _ := json.Marshal(map[string]interface{}{"files": result}) - return C.CString(string(data)) -} - -//export AGFS_Read -func AGFS_Read(clientID int64, path *C.char, offset C.int64_t, size C.int64_t, outData **C.char, outSize *C.int64_t) C.int64_t { - p := C.GoString(path) - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - data, err := fs.Read(p, int64(offset), int64(size)) - if err != nil && err.Error() != "EOF" { - errorID := storeError(err) - return C.int64_t(errorID) - } - - if len(data) > 0 { - buf := C.malloc(C.size_t(len(data))) - C.memcpy(buf, unsafe.Pointer(&data[0]), C.size_t(len(data))) - *outData = (*C.char)(buf) - *outSize = C.int64_t(len(data)) - } else { - *outData = nil - *outSize = 0 - } - return 0 -} - -//export AGFS_Write -func AGFS_Write(clientID int64, path *C.char, data unsafe.Pointer, dataSize C.int64_t) *C.char { - p := C.GoString(path) - bytesData := C.GoBytes(data, C.int(dataSize)) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - n, err := fs.Write(p, bytesData, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(fmt.Sprintf(`{"message": "Written %d bytes"}`, n)) -} - -//export AGFS_Create -func AGFS_Create(clientID int64, path *C.char) *C.char { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - err := fs.Create(p) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "file created"}`) -} - -//export AGFS_Mkdir -func AGFS_Mkdir(clientID int64, path *C.char, mode C.uint) *C.char { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - err := fs.Mkdir(p, uint32(mode)) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "directory created"}`) -} - -//export AGFS_Rm -func AGFS_Rm(clientID int64, path *C.char, recursive C.int) *C.char { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - var err error - if recursive != 0 { - err = fs.RemoveAll(p) - } else { - err = fs.Remove(p) - } - - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "deleted"}`) -} - -//export AGFS_Stat -func AGFS_Stat(clientID int64, path *C.char) *C.char { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - info, err := fs.Stat(p) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - result := map[string]interface{}{ - "name": info.Name, - "size": info.Size, - "mode": info.Mode, - "modTime": info.ModTime.Format(time.RFC3339Nano), - "isDir": info.IsDir, - } - - data, _ := json.Marshal(result) - return C.CString(string(data)) -} - -//export AGFS_Mv -func AGFS_Mv(clientID int64, oldPath *C.char, newPath *C.char) *C.char { - oldP := C.GoString(oldPath) - newP := C.GoString(newPath) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - err := fs.Rename(oldP, newP) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "renamed"}`) -} - -//export AGFS_Chmod -func AGFS_Chmod(clientID int64, path *C.char, mode C.uint) *C.char { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - err := fs.Chmod(p, uint32(mode)) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "permissions changed"}`) -} - -//export AGFS_Touch -func AGFS_Touch(clientID int64, path *C.char) *C.char { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - err := fs.Touch(p) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "touched"}`) -} - -//export AGFS_Mounts -func AGFS_Mounts(clientID int64) *C.char { - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - mounts := fs.GetMounts() - result := make([]map[string]interface{}, len(mounts)) - for i, m := range mounts { - result[i] = map[string]interface{}{ - "path": m.Path, - "fstype": m.Plugin.Name(), - } - } - - data, _ := json.Marshal(map[string]interface{}{"mounts": result}) - return C.CString(string(data)) -} - -//export AGFS_Mount -func AGFS_Mount(clientID int64, fstype *C.char, path *C.char, configJSON *C.char) *C.char { - fsType := C.GoString(fstype) - p := C.GoString(path) - cfgJSON := C.GoString(configJSON) - - var config map[string]interface{} - if err := json.Unmarshal([]byte(cfgJSON), &config); err != nil { - config = make(map[string]interface{}) - } - - globalFSMu.Lock() - fs := globalFS - globalFSMu.Unlock() - - err := fs.MountPlugin(fsType, p, config) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(fmt.Sprintf(`{"message": "mounted %s at %s"}`, fsType, p)) -} - -//export AGFS_Unmount -func AGFS_Unmount(clientID int64, path *C.char) *C.char { - p := C.GoString(path) - - globalFSMu.Lock() - fs := globalFS - globalFSMu.Unlock() - - err := fs.Unmount(p) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "unmounted"}`) -} - -//export AGFS_LoadPlugin -func AGFS_LoadPlugin(clientID int64, libraryPath *C.char) *C.char { - libPath := C.GoString(libraryPath) - - globalFSMu.Lock() - fs := globalFS - globalFSMu.Unlock() - - p, err := fs.LoadExternalPlugin(libPath) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(fmt.Sprintf(`{"message": "loaded plugin %s", "name": "%s"}`, libPath, p.Name())) -} - -//export AGFS_UnloadPlugin -func AGFS_UnloadPlugin(clientID int64, libraryPath *C.char) *C.char { - libPath := C.GoString(libraryPath) - - globalFSMu.Lock() - fs := globalFS - globalFSMu.Unlock() - - err := fs.UnloadExternalPlugin(libPath) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "unloaded plugin"}`) -} - -//export AGFS_ListPlugins -func AGFS_ListPlugins(clientID int64) *C.char { - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - plugins := fs.GetLoadedExternalPlugins() - data, _ := json.Marshal(map[string]interface{}{"loaded_plugins": plugins}) - return C.CString(string(data)) -} - -//export AGFS_OpenHandle -func AGFS_OpenHandle(clientID int64, path *C.char, flags C.int, mode C.uint, lease C.int) C.int64_t { - p := C.GoString(path) - - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - handle, err := fs.OpenHandle(p, filesystem.OpenFlag(flags), uint32(mode)) - if err != nil { - storeError(err) - return -1 - } - - id := storeHandle(handle) - return C.int64_t(id) -} - -//export AGFS_CloseHandle -func AGFS_CloseHandle(handleID C.int64_t) *C.char { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - return C.CString(`{"error_id": 0}`) - } - - err := handle.Close() - removeHandle(id) - - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - return C.CString(`{"message": "handle closed"}`) -} - -//export AGFS_HandleRead -func AGFS_HandleRead(handleID C.int64_t, size C.int64_t, offset C.int64_t, hasOffset C.int) (*C.char, C.int64_t, C.int64_t) { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - errJSON := fmt.Sprintf(`{"error_id": %d}`, storeError(fmt.Errorf("handle not found"))) - return C.CString(errJSON), 0, -1 - } - - buf := make([]byte, int(size)) - var n int - var err error - - if hasOffset != 0 { - n, err = handle.ReadAt(buf, int64(offset)) - } else { - n, err = handle.Read(buf) - } - - if err != nil && err.Error() != "EOF" { - errJSON := fmt.Sprintf(`{"error_id": %d}`, storeError(err)) - return C.CString(errJSON), 0, -1 - } - - return C.CString(string(buf[:n])), C.int64_t(n), 0 -} - -//export AGFS_HandleWrite -func AGFS_HandleWrite(handleID C.int64_t, data unsafe.Pointer, dataSize C.int64_t, offset C.int64_t, hasOffset C.int) *C.char { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(fmt.Errorf("handle not found")))) - } - - bytesData := C.GoBytes(data, C.int(dataSize)) - var n int - var err error - - if hasOffset != 0 { - n, err = handle.WriteAt(bytesData, int64(offset)) - } else { - n, err = handle.Write(bytesData) - } - - if err != nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(err))) - } - - return C.CString(fmt.Sprintf(`{"bytes_written": %d}`, n)) -} - -//export AGFS_HandleSeek -func AGFS_HandleSeek(handleID C.int64_t, offset C.int64_t, whence C.int) *C.char { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(fmt.Errorf("handle not found")))) - } - - newPos, err := handle.Seek(int64(offset), int(whence)) - if err != nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(err))) - } - - return C.CString(fmt.Sprintf(`{"position": %d}`, newPos)) -} - -//export AGFS_HandleSync -func AGFS_HandleSync(handleID C.int64_t) *C.char { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(fmt.Errorf("handle not found")))) - } - - err := handle.Sync() - if err != nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(err))) - } - - return C.CString(`{"message": "synced"}`) -} - -//export AGFS_HandleStat -func AGFS_HandleStat(handleID C.int64_t) *C.char { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(fmt.Errorf("handle not found")))) - } - - info, err := handle.Stat() - if err != nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(err))) - } - - result := map[string]interface{}{ - "name": info.Name, - "size": info.Size, - "mode": info.Mode, - "modTime": info.ModTime.Format(time.RFC3339Nano), - "isDir": info.IsDir, - } - - data, _ := json.Marshal(result) - return C.CString(string(data)) -} - -//export AGFS_ListHandles -func AGFS_ListHandles(clientID int64) *C.char { - handleMapMu.RLock() - handles := make([]map[string]interface{}, 0, len(handleMap)) - for id, h := range handleMap { - handles = append(handles, map[string]interface{}{ - "handle_id": id, - "path": h.Path(), - }) - } - handleMapMu.RUnlock() - - data, _ := json.Marshal(map[string]interface{}{"handles": handles}) - return C.CString(string(data)) -} - -//export AGFS_GetHandleInfo -func AGFS_GetHandleInfo(handleID C.int64_t) *C.char { - id := int64(handleID) - handle := getHandle(id) - if handle == nil { - return C.CString(fmt.Sprintf(`{"error_id": %d}`, storeError(fmt.Errorf("handle not found")))) - } - - result := map[string]interface{}{ - "handle_id": id, - "path": handle.Path(), - "flags": int(handle.Flags()), - } - - data, _ := json.Marshal(result) - return C.CString(string(data)) -} - -//export AGFS_GetPluginLoader -func AGFS_GetPluginLoader() unsafe.Pointer { - globalFSMu.RLock() - fs := globalFS - globalFSMu.RUnlock() - - l := fs.GetPluginLoader() - return unsafe.Pointer(l) -} - -// GrepMatch represents a single match result -type GrepMatch struct { - File string `json:"file"` - Line int `json:"line"` - Content string `json:"content"` -} - -// GrepResponse represents the grep search results -type GrepResponse struct { - Matches []GrepMatch `json:"matches"` - Count int `json:"count"` -} - -func grepFile(fs *mountablefs.MountableFS, path string, re *regexp.Regexp, nodeLimit int) ([]GrepMatch, error) { - data, err := fs.Read(path, 0, -1) - if err != nil && err.Error() != "EOF" { - return nil, err - } - - var matches []GrepMatch - scanner := bufio.NewScanner(bytes.NewReader(data)) - lineNum := 1 - - for scanner.Scan() { - if nodeLimit > 0 && len(matches) >= nodeLimit { - break - } - line := scanner.Text() - if re.MatchString(line) { - matches = append(matches, GrepMatch{ - File: path, - Line: lineNum, - Content: line, - }) - } - lineNum++ - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - return matches, nil -} - -func grepDirectory(fs *mountablefs.MountableFS, dirPath string, re *regexp.Regexp, nodeLimit int) ([]GrepMatch, error) { - var allMatches []GrepMatch - - entries, err := fs.ReadDir(dirPath) - if err != nil { - return nil, err - } - - for _, entry := range entries { - if nodeLimit > 0 && len(allMatches) >= nodeLimit { - break - } - fullPath := path.Join(dirPath, entry.Name) - - if entry.IsDir { - subMatches, err := grepDirectory(fs, fullPath, re, nodeLimit-len(allMatches)) - if err != nil { - log.Warnf("failed to search directory %s: %v", fullPath, err) - continue - } - allMatches = append(allMatches, subMatches...) - } else { - matches, err := grepFile(fs, fullPath, re, nodeLimit-len(allMatches)) - if err != nil { - log.Warnf("failed to search file %s: %v", fullPath, err) - continue - } - allMatches = append(allMatches, matches...) - } - } - - return allMatches, nil -} - -//export AGFS_Grep -func AGFS_Grep(clientID int64, path *C.char, pattern *C.char, recursive C.int, caseInsensitive C.int, stream C.int, nodeLimit C.int) *C.char { - p := C.GoString(path) - pat := C.GoString(pattern) - nodeLim := int(nodeLimit) - - globalFSMu.RLock() - defer globalFSMu.RUnlock() - fs := globalFS - - info, err := fs.Stat(p) - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - var re *regexp.Regexp - if caseInsensitive != 0 { - re, err = regexp.Compile("(?i)" + pat) - } else { - re, err = regexp.Compile(pat) - } - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - var matches []GrepMatch - if info.IsDir { - if recursive == 0 { - errorID := storeError(fmt.Errorf("path is a directory, use recursive=true to search")) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - matches, err = grepDirectory(fs, p, re, nodeLim) - } else { - matches, err = grepFile(fs, p, re, nodeLim) - } - - if err != nil { - errorID := storeError(err) - return C.CString(fmt.Sprintf(`{"error_id": %d}`, errorID)) - } - - response := GrepResponse{ - Matches: matches, - Count: len(matches), - } - - data, _ := json.Marshal(response) - return C.CString(string(data)) -} - -func GetMountableFS() *mountablefs.MountableFS { - globalFSMu.RLock() - defer globalFSMu.RUnlock() - return globalFS -} - -func SetMountableFS(fs *mountablefs.MountableFS) { - globalFSMu.Lock() - globalFS = fs - globalFSMu.Unlock() -} - -func GetPluginLoaderInternal() *loader.PluginLoader { - return globalFS.GetPluginLoader() -} - -func main() {} diff --git a/third_party/agfs/agfs-server/cmd/server/main.go b/third_party/agfs/agfs-server/cmd/server/main.go deleted file mode 100644 index e212a48e9..000000000 --- a/third_party/agfs/agfs-server/cmd/server/main.go +++ /dev/null @@ -1,378 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "net/http" - "path/filepath" - "runtime" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/config" - "github.com/c4pt0r/agfs/agfs-server/pkg/handlers" - "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/gptfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/heartbeatfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/hellofs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/httpfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/kvfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/localfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/memfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/proxyfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/queuefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/s3fs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/serverinfofs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/sqlfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/sqlfs2" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/streamfs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/streamrotatefs" - log "github.com/sirupsen/logrus" -) - -var ( - // Version information, injected during build - Version = "1.4.0" - BuildTime = "unknown" - GitCommit = "unknown" -) - -// PluginFactory is a function that creates a new plugin instance -type PluginFactory func() plugin.ServicePlugin - -// availablePlugins maps plugin names to their factory functions -var availablePlugins = map[string]PluginFactory{ - "serverinfofs": func() plugin.ServicePlugin { return serverinfofs.NewServerInfoFSPlugin() }, - "memfs": func() plugin.ServicePlugin { return memfs.NewMemFSPlugin() }, - "queuefs": func() plugin.ServicePlugin { return queuefs.NewQueueFSPlugin() }, - "kvfs": func() plugin.ServicePlugin { return kvfs.NewKVFSPlugin() }, - "hellofs": func() plugin.ServicePlugin { return hellofs.NewHelloFSPlugin() }, - "heartbeatfs": func() plugin.ServicePlugin { return heartbeatfs.NewHeartbeatFSPlugin() }, - "httpfs": func() plugin.ServicePlugin { return httpfs.NewHTTPFSPlugin() }, - "proxyfs": func() plugin.ServicePlugin { return proxyfs.NewProxyFSPlugin("") }, - "s3fs": func() plugin.ServicePlugin { return s3fs.NewS3FSPlugin() }, - "streamfs": func() plugin.ServicePlugin { return streamfs.NewStreamFSPlugin() }, - "streamrotatefs": func() plugin.ServicePlugin { return streamrotatefs.NewStreamRotateFSPlugin() }, - "sqlfs": func() plugin.ServicePlugin { return sqlfs.NewSQLFSPlugin() }, - "sqlfs2": func() plugin.ServicePlugin { return sqlfs2.NewSQLFS2Plugin() }, - "localfs": func() plugin.ServicePlugin { return localfs.NewLocalFSPlugin() }, - "gptfs": func() plugin.ServicePlugin { return gptfs.NewGptfs() }, -} - -const sampleConfig = `# AGFS Server Configuration File -# This is a sample configuration showing all available options - -server: - address: ":8080" # Server listen address - log_level: "info" # Log level: debug, info, warn, error - -# Plugin configurations -plugins: - # Server Info Plugin - provides server information and stats - serverinfofs: - enabled: true - path: "/serverinfofs" - - # Memory File System - in-memory file storage - memfs: - enabled: true - path: "/memfs" - - # Queue File System - message queue operations - queuefs: - enabled: true - path: "/queuefs" - - # Key-Value File System - key-value store - kvfs: - enabled: true - path: "/kvfs" - - # Hello File System - example plugin - hellofs: - enabled: true - path: "/hellofs" - - # Stream File System - streaming file operations - streamfs: - enabled: true - path: "/streamfs" - - # Local File System - mount local directories - localfs: - enabled: false - path: "/localfs" - config: - root_path: "/path/to/local/directory" # Local directory to mount - - # S3 File System - mount S3 buckets - s3fs: - enabled: false - path: "/s3fs" - config: - bucket: "your-bucket-name" - region: "us-west-2" - access_key: "YOUR_ACCESS_KEY" - secret_key: "YOUR_SECRET_KEY" - endpoint: "" # Optional: custom S3 endpoint - - # SQL File System - file system backed by SQL database - sqlfs: - enabled: false - # Multi-instance example: mount multiple SQL databases - instances: - - name: "sqlfs-sqlite" - enabled: true - path: "/sqlfs/sqlite" - config: - backend: "sqlite" - db_path: "/tmp/agfs-sqlite.db" - - - name: "sqlfs-postgres" - enabled: false - path: "/sqlfs/postgres" - config: - backend: "postgres" - connection_string: "postgres://user:pass@localhost/dbname?sslmode=disable" - - # Proxy File System - proxy to another AGFS server - proxyfs: - enabled: false - # Multi-instance example: proxy multiple remote servers - instances: - - name: "proxy-remote1" - enabled: true - path: "/proxy/remote1" - config: - base_url: "http://remote-server-1:8080/api/v1" - remote_path: "/" - - - name: "proxy-remote2" - enabled: false - path: "/proxy/remote2" - config: - base_url: "http://remote-server-2:8080/api/v1" - remote_path: "/memfs" -` - -func main() { - configFile := flag.String("c", "config.yaml", "Path to configuration file") - addr := flag.String("addr", "", "Server listen address (will override addr in config file)") - printSampleConfig := flag.Bool("print-sample-config", false, "Print a sample configuration file and exit") - version := flag.Bool("version", false, "Print version information and exit") - flag.Parse() - - // Handle --version - if *version { - fmt.Printf("agfs-server version: %s\n", Version) - fmt.Printf("Git commit: %s\n", GitCommit) - fmt.Printf("Build time: %s\n", BuildTime) - return - } - - // Handle --print-sample-config - if *printSampleConfig { - fmt.Print(sampleConfig) - return - } - - // Load configuration - cfg, err := config.LoadConfig(*configFile) - if err != nil { - log.Fatalf("Failed to load config file: %v", err) - } - - // Configure logrus - logLevel := log.InfoLevel - if cfg.Server.LogLevel != "" { - if level, err := log.ParseLevel(cfg.Server.LogLevel); err == nil { - logLevel = level - } - } - log.SetFormatter(&log.TextFormatter{ - FullTimestamp: true, - CallerPrettyfier: func(f *runtime.Frame) (string, string) { - filename := filepath.Base(f.File) - return "", fmt.Sprintf(" | %s:%d | ", filename, f.Line) - }, - }) - log.SetReportCaller(true) - log.SetLevel(logLevel) - - // Determine server address - serverAddr := cfg.Server.Address - if *addr != "" { - serverAddr = *addr // Command line override - } - if serverAddr == "" { - serverAddr = ":8080" // Default - } - - // Create WASM instance pool configuration from config - wasmConfig := cfg.GetWASMConfig() - poolConfig := api.PoolConfig{ - MaxInstances: wasmConfig.InstancePoolSize, - InstanceMaxLifetime: time.Duration(wasmConfig.InstanceMaxLifetime) * time.Second, - InstanceMaxRequests: int64(wasmConfig.InstanceMaxRequests), - HealthCheckInterval: time.Duration(wasmConfig.HealthCheckInterval) * time.Second, - EnableStatistics: wasmConfig.EnablePoolStatistics, - } - - // Create mountable file system - mfs := mountablefs.NewMountableFS(poolConfig) - - // Create traffic monitor early so it can be injected into plugins during mounting - trafficMonitor := handlers.NewTrafficMonitor() - - // Register plugin factories for dynamic mounting - for pluginName, factory := range availablePlugins { - // Capture factory in local variable to avoid closure issues - f := factory - mfs.RegisterPluginFactory(pluginName, func() plugin.ServicePlugin { - return f() - }) - } - - // mountPlugin initializes and mounts a plugin asynchronously - mountPlugin := func(pluginName, instanceName, mountPath string, pluginConfig map[string]interface{}) { - // Get plugin factory (try built-in first, then external) - factory, ok := availablePlugins[pluginName] - var p plugin.ServicePlugin - - if !ok { - // Try to get external plugin from mfs - p = mfs.CreatePlugin(pluginName) - if p == nil { - log.Warnf("Unknown plugin: %s, skipping instance '%s'", pluginName, instanceName) - return - } - } else { - // Create plugin instance from built-in factory - p = factory() - } - - // Special handling for httpfs: inject rootFS reference - if pluginName == "httpfs" { - if httpfsPlugin, ok := p.(*httpfs.HTTPFSPlugin); ok { - httpfsPlugin.SetRootFS(mfs) - } - } - - // Special handling for serverinfofs: inject traffic monitor - if pluginName == "serverinfofs" { - if serverInfoPlugin, ok := p.(*serverinfofs.ServerInfoFSPlugin); ok { - serverInfoPlugin.SetTrafficMonitor(trafficMonitor) - } - } - - // Mount asynchronously - go func() { - // Inject mount_path into config - configWithPath := make(map[string]interface{}) - for k, v := range pluginConfig { - configWithPath[k] = v - } - configWithPath["mount_path"] = mountPath - - // Validate plugin configuration - if err := p.Validate(configWithPath); err != nil { - log.Errorf("Failed to validate %s instance '%s': %v", pluginName, instanceName, err) - return - } - - // Initialize plugin - if err := p.Initialize(configWithPath); err != nil { - log.Errorf("Failed to initialize %s instance '%s': %v", pluginName, instanceName, err) - return - } - - // Mount plugin - if err := mfs.Mount(mountPath, p); err != nil { - log.Errorf("Failed to mount %s instance '%s' at %s: %v", pluginName, instanceName, mountPath, err) - return - } - - // Log success - log.Infof("%s instance '%s' mounted at %s", pluginName, instanceName, mountPath) - }() - } - - // Load external plugins if enabled - if cfg.ExternalPlugins.Enabled { - log.Info("Loading external plugins...") - - // Auto-load from plugin directory - if cfg.ExternalPlugins.AutoLoad && cfg.ExternalPlugins.PluginDir != "" { - log.Infof("Auto-loading plugins from: %s", cfg.ExternalPlugins.PluginDir) - loaded, errors := mfs.LoadExternalPluginsFromDirectory(cfg.ExternalPlugins.PluginDir) - if len(errors) > 0 { - log.Warnf("Encountered %d error(s) while loading plugins:", len(errors)) - for _, err := range errors { - log.Warnf("- %v", err) - } - } - if len(loaded) > 0 { - log.Infof("Auto-loaded %d plugin(s)", len(loaded)) - } - } - - // Load specific plugin paths - for _, pluginPath := range cfg.ExternalPlugins.PluginPaths { - log.Infof("Loading plugin: %s", pluginPath) - p, err := mfs.LoadExternalPlugin(pluginPath) - if err != nil { - log.Errorf("Failed to load plugin %s: %v", pluginPath, err) - } else { - log.Infof("Loaded plugin: %s", p.Name()) - } - } - } - - // Mount all enabled plugins - log.Info("Mounting plugin filesytems...") - for pluginName, pluginCfg := range cfg.Plugins { - // Normalize to instance array (convert single instance to array of one) - instances := pluginCfg.Instances - if len(instances) == 0 { - // Single instance mode: treat as array with one instance - instances = []config.PluginInstance{ - { - Name: pluginName, // Use plugin name as instance name - Enabled: pluginCfg.Enabled, - Path: pluginCfg.Path, - Config: pluginCfg.Config, - }, - } - } - - // Mount all instances - for _, instance := range instances { - if !instance.Enabled { - log.Infof("%s instance '%s' is disabled, skipping", pluginName, instance.Name) - continue - } - - mountPlugin(pluginName, instance.Name, instance.Path, instance.Config) - } - } - - // Create handlers - handler := handlers.NewHandler(mfs, trafficMonitor) - handler.SetVersionInfo(Version, GitCommit, BuildTime) - pluginHandler := handlers.NewPluginHandler(mfs) - - // Setup routes - mux := http.NewServeMux() - handler.SetupRoutes(mux) - pluginHandler.SetupRoutes(mux) - - // Wrap with logging middleware - loggedMux := handlers.LoggingMiddleware(mux) - // Start server - log.Infof("Starting AGFS server on %s", serverAddr) - - if err := http.ListenAndServe(serverAddr, loggedMux); err != nil { - log.Fatal(err) - } -} diff --git a/third_party/agfs/agfs-server/config.example.yaml b/third_party/agfs/agfs-server/config.example.yaml deleted file mode 100644 index a91050f5e..000000000 --- a/third_party/agfs/agfs-server/config.example.yaml +++ /dev/null @@ -1,290 +0,0 @@ -# AGFS Server Configuration File - -server: - address: ":8080" - log_level: info # Options: debug, info, warn, error - -plugins: - serverinfofs: - enabled: true - path: /serverinfo - config: - version: "1.0.0" - - queuefs: - enabled: true - path: /queuefs - config: {} - - localfs: - enabled: true - path: /local - config: - local_dir: /data - -# ============================================================================ -# Plugin Configurations -# ============================================================================ -# Plugins can be defined as: -# 1. Single instance: { enabled, path, config } -# 2. Multiple instances: array of { name, enabled, path, config } - -#plugins: -# serverinfofs: -# enabled: true -# path: /serverinfofs -# config: -# version: "1.0.0" -# -# memfs: -# enabled: true -# path: /memfs -# config: -# init_dirs: -# - /home -# - /tmp -# -# queuefs: -# enabled: true -# path: /queuefs -# config: {} -# -# kvfs: -# enabled: true -# path: /kvfs -# config: -# initial_data: -# welcome: "Hello from AGFS Server!" -# version: "1.0.0" -# -# hellofs: -# enabled: true -# path: /hellofs -# -# streamfs: -# enabled: true -# path: /streamfs -# -# # ============================================================================ -# # LocalFS - Local File System Mount -# # ============================================================================ -# localfs: -# enabled: true -# path: /local/tmp -# config: -# local_dir: /tmp # Path to the local directory to mount -# -# # Example: Multiple local mounts (uncomment to use) -# # localfs_home: -# # enabled: false -# # path: /home -# # config: -# # local_dir: /Users/username # Mount home directory -# -# # localfs_data: -# # enabled: false -# # path: /data -# # config: -# # local_dir: /var/data # Mount data directory -# -# # ============================================================================ -# # SQLFS - Database-backed File System (Multiple Instances) -# # ============================================================================ -# sqlfs: -# # SQLite instance for local development -# - name: local -# enabled: true -# path: /sqlfs -# config: -# backend: sqlite -# db_path: sqlfs.db -# cache_enabled: true -# cache_max_size: 1000 -# cache_ttl_seconds: 5 -# -# # TiDB instance for production (disabled by default) -# - name: tidb -# enabled: true -# path: /sqlfs_tidb -# config: -# backend: tidb -# dsn: "<username>:<password>@tcp(addr)/<dbname>?charset=utf8mb4&parseTime=True&tls=tidb" -# cache_enabled: true -# cache_max_size: 1000 -# cache_ttl_seconds: 5 -# -# sqlfs2: -# enabled: true -# path: "/sqlfs2/tidb" -# config: -# backend: "tidb" -# dsn: "<username>:<password>@tcp(addr)/<dbname>?charset=utf8mb4&parseTime=True&tls=tidb" -# -# -# # ============================================================================ -# # ProxyFS - Remote AGFS Proxy (Multiple Instances) -# # ============================================================================ -# proxyfs: -# # Remote server 1 (disabled by default) -# - name: remote1 -# enabled: false -# path: /proxyfs/remote1 -# config: -# base_url: "http://localhost:9090/api/v1" -# -# # Remote server 2 (disabled by default) -# - name: remote2 -# enabled: false -# path: /proxyfs/remote2 -# config: -# base_url: "http://another-server:8080/api/v1" -# -# s3fs: -# - name: aws -# enabled: true -# path: /s3fs/aws -# config: -# region: us-west-1 -# bucket: bucket-name -# access_key_id: key_id -# secret_access_key: secret -# prefix: agfs/ # Optional: all keys will be prefixed with "agfs/" -# use_path_style: true # Optional: enable path-style addressing (required for MinIO etc.) -# -# # ============================================================================ -# # HTTPFS - HTTP File Server (Multiple Instances) -# # ============================================================================ -# # HTTPFS serves AGFS paths over HTTP, similar to 'python3 -m http.server'. -# # Each instance can serve a different AGFS path on a different port. -# # -# # Features: -# # - Serve any AGFS filesystem (memfs, queuefs, s3fs, etc.) via HTTP -# # - Browse directories and download files in web browser -# # - README files display inline instead of downloading -# # - Virtual status file: 'agfs cat /httagfs-memfs' shows instance info -# # - Dynamic mounting: 'agfs mount httagfs /path agfs_path=/memfs http_port=9000' -# # -# httagfs: -# # Instance 1: Serve memfs on port 9000 -# - name: httagfs-memfs -# enabled: true -# path: /httagfs-memfs -# config: -# agfs_path: /memfs # The AGFS path to serve -# port: "9000" # HTTP server port -# -# # Instance 2: Serve queuefs on port 9001 (disabled by default) -# - name: httagfs-queue -# enabled: false -# path: /httagfs-queue -# config: -# agfs_path: /queuefs -# port: "9001" -# -# # Instance 3: Serve S3 content on port 9002 (disabled by default) -# - name: httagfs-s3 -# enabled: false -# path: /httagfs-s3 -# config: -# agfs_path: /s3fs/aws -# port: "9002" -# host: "localhost" - - # Example: Single instance httagfs (uncomment to use) - # httagfs_public: - # enabled: false - # path: /httagfs-public - # config: - # agfs_path: /memfs/public # Serve only public directory - # http_port: "8000" # Default port - - # Note: You can also mount httagfs dynamically at runtime: - # agfs mount httagfs /httagfs-temp agfs_path=/memfs http_port=10000 - # agfs cat /httagfs-temp # View instance status - # agfs unmount /httagfs-temp # Remove when done - - # gptfs: - # enabled: true - # path: /gptfs - # config: - # api_host: "https://openrouter.ai/api/v1/chat/completions" - # api_key: "" - # workers: 3 - # - -# ============================================================================ -# File System Structure -# ============================================================================ -# With current enabled plugins: -# / -# ├── serverinfofs/ (server information) -# ├── memfs/ (in-memory filesystem) -# ├── queuefs/ (message queue) -# ├── kvfs/ (key-value store) -# ├── hellofs/ (hello world plugin) -# ├── streamfs/ (streaming data) -# ├── sqlfs/ (database-backed filesystem - SQLite) -# ├── httagfs-memfs (virtual status file for httagfs instance) -# └── local/ -# └── tmp/ (local file system mount) -# -# If additional instances are enabled: -# ├── sqlfs_tidb/ (database-backed filesystem - TiDB) -# ├── httagfs-queue (virtual status file - httagfs serving queuefs) -# ├── httagfs-s3 (virtual status file - httagfs serving S3) -# ├── s3fs/ -# │ └── aws/ (AWS S3 storage) -# └── proxyfs/ -# ├── remote1/ (remote AGFS server 1) -# └── remote2/ (remote AGFS server 2) -# -# HTTPFS instances are also accessible via HTTP: -# - http://localhost:9000/ -> serves /memfs -# - http://localhost:9001/ -> serves /queuefs (if enabled) -# - http://localhost:9002/ -> serves /s3fs/aws (if enabled) - -# ============================================================================ -# Usage Examples -# ============================================================================ -# -# Access local plugins: -# agfs ls /memfs -# agfs cat /streamfs/video -# agfs write /sqlfs/data/config.txt "data" -# -# Access local file system (when enabled): -# agfs ls /local -# agfs cat /local/file.txt -# agfs write /local/newfile.txt "content" -# -# Access remote endpoints (when enabled): -# agfs ls /proxyfs/remote1 -# agfs cat /proxyfs/remote1/hello.txt -# -# Access S3 storage (when enabled): -# agfs ls /s3fs/aws -# agfs write /s3fs/aws/data.txt "cloud data" -# -# Multiple SQLFS instances (when enabled): -# agfs write /sqlfs/local-data.txt "local" -# agfs write /sqlfs_tidb/prod-data.txt "production" -# -# HTTPFS - HTTP File Server: -# # View httagfs instance status -# agfs cat /httagfs-memfs -# -# # Access via HTTP (browser or curl) -# curl http://localhost:9000/ # List directory -# curl http://localhost:9000/file.txt # Download file -# # Or open in browser: http://localhost:9000/ -# -# # Dynamic mounting (create temporary HTTP servers) -# agfs mount httagfs /temp-http agfs_path=/memfs http_port=10000 -# agfs cat /temp-http # Check status -# # Now accessible at http://localhost:10000/ -# agfs unmount /temp-http # Remove when done -# -# # Multiple instances serving different content -# agfs mount httagfs /docs agfs_path=/memfs/docs http_port=8001 -# agfs mount httagfs /images agfs_path=/memfs/images http_port=8002 -# agfs mount httagfs /s3-public agfs_path=/s3fs/aws/public http_port=8003 diff --git a/third_party/agfs/agfs-server/config.yaml b/third_party/agfs/agfs-server/config.yaml deleted file mode 100644 index b39d34869..000000000 --- a/third_party/agfs/agfs-server/config.yaml +++ /dev/null @@ -1,289 +0,0 @@ -# AGFS Server Configuration File - -server: - address: ":8080" - log_level: info # Options: debug, info, warn, error - -plugins: - serverinfofs: - enabled: true - path: /serverinfo - config: - version: "1.0.0" - - queuefs: - enabled: true - path: /queuefs - config: {} - - localfs: - enabled: true - path: /local - config: - local_dir: ../../../data - -# ============================================================================ -# Plugin Configurations -# ============================================================================ -# Plugins can be defined as: -# 1. Single instance: { enabled, path, config } -# 2. Multiple instances: array of { name, enabled, path, config } - -#plugins: -# serverinfofs: -# enabled: true -# path: /serverinfofs -# config: -# version: "1.0.0" -# -# memfs: -# enabled: true -# path: /memfs -# config: -# init_dirs: -# - /home -# - /tmp -# -# queuefs: -# enabled: true -# path: /queuefs -# config: {} -# -# kvfs: -# enabled: true -# path: /kvfs -# config: -# initial_data: -# welcome: "Hello from AGFS Server!" -# version: "1.0.0" -# -# hellofs: -# enabled: true -# path: /hellofs -# -# streamfs: -# enabled: true -# path: /streamfs -# -# # ============================================================================ -# # LocalFS - Local File System Mount -# # ============================================================================ -# localfs: -# enabled: true -# path: /local/tmp -# config: -# local_dir: /tmp # Path to the local directory to mount -# -# # Example: Multiple local mounts (uncomment to use) -# # localfs_home: -# # enabled: false -# # path: /home -# # config: -# # local_dir: /Users/username # Mount home directory -# -# # localfs_data: -# # enabled: false -# # path: /data -# # config: -# # local_dir: /var/data # Mount data directory -# -# # ============================================================================ -# # SQLFS - Database-backed File System (Multiple Instances) -# # ============================================================================ -# sqlfs: -# # SQLite instance for local development -# - name: local -# enabled: true -# path: /sqlfs -# config: -# backend: sqlite -# db_path: sqlfs.db -# cache_enabled: true -# cache_max_size: 1000 -# cache_ttl_seconds: 5 -# -# # TiDB instance for production (disabled by default) -# - name: tidb -# enabled: true -# path: /sqlfs_tidb -# config: -# backend: tidb -# dsn: "<username>:<password>@tcp(addr)/<dbname>?charset=utf8mb4&parseTime=True&tls=tidb" -# cache_enabled: true -# cache_max_size: 1000 -# cache_ttl_seconds: 5 -# -# sqlfs2: -# enabled: true -# path: "/sqlfs2/tidb" -# config: -# backend: "tidb" -# dsn: "<username>:<password>@tcp(addr)/<dbname>?charset=utf8mb4&parseTime=True&tls=tidb" -# -# -# # ============================================================================ -# # ProxyFS - Remote AGFS Proxy (Multiple Instances) -# # ============================================================================ -# proxyfs: -# # Remote server 1 (disabled by default) -# - name: remote1 -# enabled: false -# path: /proxyfs/remote1 -# config: -# base_url: "http://localhost:9090/api/v1" -# -# # Remote server 2 (disabled by default) -# - name: remote2 -# enabled: false -# path: /proxyfs/remote2 -# config: -# base_url: "http://another-server:8080/api/v1" -# -# s3fs: -# - name: aws -# enabled: true -# path: /s3fs/aws -# config: -# region: us-west-1 -# bucket: bucket-name -# access_key_id: key_id -# secret_access_key: secret -# prefix: agfs/ # Optional: all keys will be prefixed with "agfs/" -# -# # ============================================================================ -# # HTTPFS - HTTP File Server (Multiple Instances) -# # ============================================================================ -# # HTTPFS serves AGFS paths over HTTP, similar to 'python3 -m http.server'. -# # Each instance can serve a different AGFS path on a different port. -# # -# # Features: -# # - Serve any AGFS filesystem (memfs, queuefs, s3fs, etc.) via HTTP -# # - Browse directories and download files in web browser -# # - README files display inline instead of downloading -# # - Virtual status file: 'agfs cat /httagfs-memfs' shows instance info -# # - Dynamic mounting: 'agfs mount httagfs /path agfs_path=/memfs http_port=9000' -# # -# httagfs: -# # Instance 1: Serve memfs on port 9000 -# - name: httagfs-memfs -# enabled: true -# path: /httagfs-memfs -# config: -# agfs_path: /memfs # The AGFS path to serve -# port: "9000" # HTTP server port -# -# # Instance 2: Serve queuefs on port 9001 (disabled by default) -# - name: httagfs-queue -# enabled: false -# path: /httagfs-queue -# config: -# agfs_path: /queuefs -# port: "9001" -# -# # Instance 3: Serve S3 content on port 9002 (disabled by default) -# - name: httagfs-s3 -# enabled: false -# path: /httagfs-s3 -# config: -# agfs_path: /s3fs/aws -# port: "9002" -# host: "localhost" - - # Example: Single instance httagfs (uncomment to use) - # httagfs_public: - # enabled: false - # path: /httagfs-public - # config: - # agfs_path: /memfs/public # Serve only public directory - # http_port: "8000" # Default port - - # Note: You can also mount httagfs dynamically at runtime: - # agfs mount httagfs /httagfs-temp agfs_path=/memfs http_port=10000 - # agfs cat /httagfs-temp # View instance status - # agfs unmount /httagfs-temp # Remove when done - - # gptfs: - # enabled: true - # path: /gptfs - # config: - # api_host: "https://openrouter.ai/api/v1/chat/completions" - # api_key: "" - # workers: 3 - # - -# ============================================================================ -# File System Structure -# ============================================================================ -# With current enabled plugins: -# / -# ├── serverinfofs/ (server information) -# ├── memfs/ (in-memory filesystem) -# ├── queuefs/ (message queue) -# ├── kvfs/ (key-value store) -# ├── hellofs/ (hello world plugin) -# ├── streamfs/ (streaming data) -# ├── sqlfs/ (database-backed filesystem - SQLite) -# ├── httagfs-memfs (virtual status file for httagfs instance) -# └── local/ -# └── tmp/ (local file system mount) -# -# If additional instances are enabled: -# ├── sqlfs_tidb/ (database-backed filesystem - TiDB) -# ├── httagfs-queue (virtual status file - httagfs serving queuefs) -# ├── httagfs-s3 (virtual status file - httagfs serving S3) -# ├── s3fs/ -# │ └── aws/ (AWS S3 storage) -# └── proxyfs/ -# ├── remote1/ (remote AGFS server 1) -# └── remote2/ (remote AGFS server 2) -# -# HTTPFS instances are also accessible via HTTP: -# - http://localhost:9000/ -> serves /memfs -# - http://localhost:9001/ -> serves /queuefs (if enabled) -# - http://localhost:9002/ -> serves /s3fs/aws (if enabled) - -# ============================================================================ -# Usage Examples -# ============================================================================ -# -# Access local plugins: -# agfs ls /memfs -# agfs cat /streamfs/video -# agfs write /sqlfs/data/config.txt "data" -# -# Access local file system (when enabled): -# agfs ls /local -# agfs cat /local/file.txt -# agfs write /local/newfile.txt "content" -# -# Access remote endpoints (when enabled): -# agfs ls /proxyfs/remote1 -# agfs cat /proxyfs/remote1/hello.txt -# -# Access S3 storage (when enabled): -# agfs ls /s3fs/aws -# agfs write /s3fs/aws/data.txt "cloud data" -# -# Multiple SQLFS instances (when enabled): -# agfs write /sqlfs/local-data.txt "local" -# agfs write /sqlfs_tidb/prod-data.txt "production" -# -# HTTPFS - HTTP File Server: -# # View httagfs instance status -# agfs cat /httagfs-memfs -# -# # Access via HTTP (browser or curl) -# curl http://localhost:9000/ # List directory -# curl http://localhost:9000/file.txt # Download file -# # Or open in browser: http://localhost:9000/ -# -# # Dynamic mounting (create temporary HTTP servers) -# agfs mount httagfs /temp-http agfs_path=/memfs http_port=10000 -# agfs cat /temp-http # Check status -# # Now accessible at http://localhost:10000/ -# agfs unmount /temp-http # Remove when done -# -# # Multiple instances serving different content -# agfs mount httagfs /docs agfs_path=/memfs/docs http_port=8001 -# agfs mount httagfs /images agfs_path=/memfs/images http_port=8002 -# agfs mount httagfs /s3-public agfs_path=/s3fs/aws/public http_port=8003 diff --git a/third_party/agfs/agfs-server/go.mod b/third_party/agfs/agfs-server/go.mod deleted file mode 100644 index 77d2f121f..000000000 --- a/third_party/agfs/agfs-server/go.mod +++ /dev/null @@ -1,43 +0,0 @@ -module github.com/c4pt0r/agfs/agfs-server - -go 1.22.0 - -require ( - github.com/aws/aws-sdk-go-v2 v1.39.2 - github.com/aws/aws-sdk-go-v2/config v1.31.12 - github.com/aws/aws-sdk-go-v2/credentials v1.18.16 - github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 - github.com/c4pt0r/agfs/agfs-sdk/go v0.0.0 - github.com/ebitengine/purego v0.9.1 - github.com/go-sql-driver/mysql v1.9.3 - github.com/google/uuid v1.6.0 - github.com/hashicorp/go-immutable-radix v1.3.1 - github.com/mattn/go-sqlite3 v1.14.32 - github.com/sirupsen/logrus v1.9.3 - github.com/tetratelabs/wazero v1.9.0 - github.com/zeebo/xxh3 v1.0.2 - gopkg.in/yaml.v3 v3.0.1 -) - -require ( - filippo.io/edwards25519 v1.1.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 // indirect - github.com/aws/smithy-go v1.23.0 // indirect - github.com/hashicorp/golang-lru v0.5.0 // indirect - github.com/klauspost/cpuid/v2 v2.0.9 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect -) - -replace github.com/c4pt0r/agfs/agfs-sdk/go => ../agfs-sdk/go diff --git a/third_party/agfs/agfs-server/go.sum b/third_party/agfs/agfs-server/go.sum deleted file mode 100644 index 7b6508b6c..000000000 --- a/third_party/agfs/agfs-server/go.sum +++ /dev/null @@ -1,77 +0,0 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/aws/aws-sdk-go-v2 v1.39.2 h1:EJLg8IdbzgeD7xgvZ+I8M1e0fL0ptn/M47lianzth0I= -github.com/aws/aws-sdk-go-v2 v1.39.2/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00= -github.com/aws/aws-sdk-go-v2/config v1.31.12 h1:pYM1Qgy0dKZLHX2cXslNacbcEFMkDMl+Bcj5ROuS6p8= -github.com/aws/aws-sdk-go-v2/config v1.31.12/go.mod h1:/MM0dyD7KSDPR+39p9ZNVKaHDLb9qnfDurvVS2KAhN8= -github.com/aws/aws-sdk-go-v2/credentials v1.18.16 h1:4JHirI4zp958zC026Sm+V4pSDwW4pwLefKrc0bF2lwI= -github.com/aws/aws-sdk-go-v2/credentials v1.18.16/go.mod h1:qQMtGx9OSw7ty1yLclzLxXCRbrkjWAM7JnObZjmCB7I= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9 h1:Mv4Bc0mWmv6oDuSWTKnk+wgeqPL5DRFu5bQL9BGPQ8Y= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.9/go.mod h1:IKlKfRppK2a1y0gy1yH6zD+yX5uplJ6UuPlgd48dJiQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9 h1:se2vOWGD3dWQUtfn4wEjRQJb1HK1XsNIt825gskZ970= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.9/go.mod h1:hijCGH2VfbZQxqCDN7bwz/4dzxV+hkyhjawAtdPWKZA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9 h1:6RBnKZLkJM4hQ+kN6E7yWFveOTg8NLPHAkqrs4ZPlTU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.9/go.mod h1:V9rQKRmK7AWuEsOMnHzKj8WyrIir1yUJbZxDuZLFvXI= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9 h1:w9LnHqTq8MEdlnyhV4Bwfizd65lfNCNgdlNC6mM5paE= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.9/go.mod h1:LGEP6EK4nj+bwWNdrvX/FnDTFowdBNwcSPuZu/ouFys= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0 h1:X0FveUndcZ3lKbSpIC6rMYGRiQTcUVRNH6X4yYtIrlU= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.0/go.mod h1:IWjQYlqw4EX9jw2g3qnEPPWvCE6bS8fKzhMed1OK7c8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9 h1:5r34CgVOD4WZudeEKZ9/iKpiT6cM1JyEROpXjOcdWv8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.9/go.mod h1:dB12CEbNWPbzO2uC6QSWHteqOg4JfBVJOojbAoAUb5I= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9 h1:wuZ5uW2uhJR63zwNlqWH2W4aL4ZjeJP3o92/W+odDY4= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.9/go.mod h1:/G58M2fGszCrOzvJUkDdY8O9kycodunH4VdT5oBAqls= -github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4 h1:mUI3b885qJgfqKDUSj6RgbRqLdX0wGmg8ruM03zNfQA= -github.com/aws/aws-sdk-go-v2/service/s3 v1.88.4/go.mod h1:6v8ukAxc7z4x4oBjGUsLnH7KGLY9Uhcgij19UJNkiMg= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.6 h1:A1oRkiSQOWstGh61y4Wc/yQ04sqrQZr1Si/oAXj20/s= -github.com/aws/aws-sdk-go-v2/service/sso v1.29.6/go.mod h1:5PfYspyCU5Vw1wNPsxi15LZovOnULudOQuVxphSflQA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1 h1:5fm5RTONng73/QA73LhCNR7UT9RpFH3hR6HWL6bIgVY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.1/go.mod h1:xBEjWD13h+6nq+z4AkqSfSvqRKFgDIQeaMguAJndOWo= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.6 h1:p3jIvqYwUZgu/XYeI48bJxOhvm47hZb5HUQ0tn6Q9kA= -github.com/aws/aws-sdk-go-v2/service/sts v1.38.6/go.mod h1:WtKK+ppze5yKPkZ0XwqIVWD4beCwv056ZbPQNoeHqM8= -github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= -github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= -github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/golang-lru v0.5.0 h1:CL2msUPvZTLb5O648aiLNJw3hnBxN2+1Jq8rCOH9wdo= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I= -github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM= -github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= -github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= -github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= -github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/third_party/agfs/agfs-server/pkg/config/config.go b/third_party/agfs/agfs-server/pkg/config/config.go deleted file mode 100644 index 50f4b09a2..000000000 --- a/third_party/agfs/agfs-server/pkg/config/config.go +++ /dev/null @@ -1,116 +0,0 @@ -package config - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// Config represents the entire configuration file -type Config struct { - Server ServerConfig `yaml:"server"` - Plugins map[string]PluginConfig `yaml:"plugins"` - ExternalPlugins ExternalPluginsConfig `yaml:"external_plugins"` -} - -// ServerConfig contains server-level configuration -type ServerConfig struct { - Address string `yaml:"address"` - LogLevel string `yaml:"log_level"` -} - -// ExternalPluginsConfig contains configuration for external plugins -type ExternalPluginsConfig struct { - Enabled bool `yaml:"enabled"` - PluginDir string `yaml:"plugin_dir"` - AutoLoad bool `yaml:"auto_load"` - PluginPaths []string `yaml:"plugin_paths"` - WASIMountPath string `yaml:"wasi_mount_path"` // Directory to mount for WASI filesystem access - WASM WASMPluginConfig `yaml:"wasm"` // WASM plugin specific configuration -} - -// WASMPluginConfig contains configuration for WASM plugins -type WASMPluginConfig struct { - InstancePoolSize int `yaml:"instance_pool_size"` // Maximum concurrent instances per plugin (default: 10) - InstanceMaxLifetime int `yaml:"instance_max_lifetime"` // Maximum instance lifetime in seconds (0 = unlimited) - InstanceMaxRequests int `yaml:"instance_max_requests"` // Maximum requests per instance (0 = unlimited) - HealthCheckInterval int `yaml:"health_check_interval"` // Health check interval in seconds (0 = disabled) - EnablePoolStatistics bool `yaml:"enable_pool_statistics"` // Enable pool statistics collection -} - -// PluginConfig can be either a single plugin or an array of plugin instances -type PluginConfig struct { - // For single instance plugins - Enabled bool `yaml:"enabled"` - Path string `yaml:"path"` - Config map[string]interface{} `yaml:"config"` - - // For multi-instance plugins (array format) - Instances []PluginInstance `yaml:"-"` -} - -// PluginInstance represents a single instance of a plugin -type PluginInstance struct { - Name string `yaml:"name"` - Enabled bool `yaml:"enabled"` - Path string `yaml:"path"` - Config map[string]interface{} `yaml:"config"` -} - -// UnmarshalYAML implements custom unmarshaling to support both single plugin and array formats -func (p *PluginConfig) UnmarshalYAML(node *yaml.Node) error { - // Try to unmarshal as array first - var instances []PluginInstance - if err := node.Decode(&instances); err == nil && len(instances) > 0 { - p.Instances = instances - return nil - } - - // Otherwise, unmarshal as single plugin config - type pluginConfigAlias PluginConfig - aux := (*pluginConfigAlias)(p) - return node.Decode(aux) -} - -// LoadConfig loads configuration from a YAML file -func LoadConfig(path string) (*Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } - - var cfg Config - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) - } - - return &cfg, nil -} - -// GetPluginConfig returns the configuration for a specific plugin -func (c *Config) GetPluginConfig(pluginName string) (PluginConfig, bool) { - cfg, ok := c.Plugins[pluginName] - return cfg, ok -} - -// GetWASMConfig returns the WASM plugin configuration with defaults applied -func (c *Config) GetWASMConfig() WASMPluginConfig { - cfg := c.ExternalPlugins.WASM - - // Apply defaults if not set - if cfg.InstancePoolSize <= 0 { - cfg.InstancePoolSize = 10 // Default: 10 concurrent instances - } - if cfg.InstanceMaxLifetime < 0 { - cfg.InstanceMaxLifetime = 0 // Default: unlimited - } - if cfg.InstanceMaxRequests < 0 { - cfg.InstanceMaxRequests = 0 // Default: unlimited - } - if cfg.HealthCheckInterval < 0 { - cfg.HealthCheckInterval = 0 // Default: disabled - } - - return cfg -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/adapters.go b/third_party/agfs/agfs-server/pkg/filesystem/adapters.go deleted file mode 100644 index 577df8a48..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/adapters.go +++ /dev/null @@ -1,340 +0,0 @@ -package filesystem - -import ( - "fmt" - "io" -) - -// BaseFileSystem provides default implementations for optional interfaces -// File systems can embed this struct to get fallback implementations -// that simulate advanced features using basic operations -type BaseFileSystem struct { - FS FileSystem -} - -// NewBaseFileSystem creates a new BaseFileSystem wrapping the given FileSystem -func NewBaseFileSystem(fs FileSystem) *BaseFileSystem { - return &BaseFileSystem{FS: fs} -} - -// WriteAt provides a default implementation of RandomWriter using Read + Modify + Write -// This is inefficient but provides compatibility for file systems that don't natively support it -func (b *BaseFileSystem) WriteAt(path string, data []byte, offset int64) (int64, error) { - if offset < 0 { - return 0, fmt.Errorf("invalid offset: %d", offset) - } - - // Get current file info - stat, err := b.FS.Stat(path) - if err != nil { - // File doesn't exist, create it with padding - if offset > 0 { - // Create file with zero padding + data - padded := make([]byte, offset+int64(len(data))) - copy(padded[offset:], data) - _, err = b.FS.Write(path, padded, -1, WriteFlagCreate|WriteFlagTruncate) - } else { - _, err = b.FS.Write(path, data, -1, WriteFlagCreate|WriteFlagTruncate) - } - if err != nil { - return 0, err - } - return int64(len(data)), nil - } - - // Read current content - currentData, err := b.FS.Read(path, 0, -1) - if err != nil && err != io.EOF { - return 0, err - } - - // Calculate new size - newSize := offset + int64(len(data)) - if newSize < stat.Size { - newSize = stat.Size - } - - // Create new content - newData := make([]byte, newSize) - copy(newData, currentData) - copy(newData[offset:], data) - - // Write back - _, err = b.FS.Write(path, newData, -1, WriteFlagTruncate) - if err != nil { - return 0, err - } - - return int64(len(data)), nil -} - -// Truncate provides a default implementation using Read + Resize + Write -func (b *BaseFileSystem) Truncate(path string, size int64) error { - if size < 0 { - return fmt.Errorf("invalid size: %d", size) - } - - // Check if file exists - stat, err := b.FS.Stat(path) - if err != nil { - return err - } - - if stat.IsDir { - return fmt.Errorf("is a directory: %s", path) - } - - // Read current content - currentData, err := b.FS.Read(path, 0, -1) - if err != nil && err != io.EOF { - return err - } - - // Resize - var newData []byte - if size <= int64(len(currentData)) { - newData = currentData[:size] - } else { - newData = make([]byte, size) - copy(newData, currentData) - // Rest is automatically zero-filled - } - - // Write back - _, err = b.FS.Write(path, newData, -1, WriteFlagTruncate) - return err -} - -// Touch provides a default implementation that creates or updates a file -func (b *BaseFileSystem) Touch(path string) error { - // Check if file exists - stat, err := b.FS.Stat(path) - if err != nil { - // File doesn't exist, create empty file - _, err = b.FS.Write(path, []byte{}, -1, WriteFlagCreate) - return err - } - - if stat.IsDir { - return fmt.Errorf("cannot touch directory: %s", path) - } - - // Read and write back to update timestamp - data, err := b.FS.Read(path, 0, -1) - if err != nil && err != io.EOF { - return err - } - - _, err = b.FS.Write(path, data, -1, WriteFlagNone) - return err -} - -// Sync provides a no-op default implementation -// Most in-memory or network file systems don't need explicit sync -func (b *BaseFileSystem) Sync(path string) error { - // Default: no-op, as most virtual file systems don't need sync - return nil -} - -// GetCapabilities returns default capabilities -func (b *BaseFileSystem) GetCapabilities() Capabilities { - return DefaultCapabilities() -} - -// GetPathCapabilities returns default capabilities for any path -func (b *BaseFileSystem) GetPathCapabilities(path string) Capabilities { - return b.GetCapabilities() -} - -// === BaseFileHandle === - -// BaseFileHandle provides a default FileHandle implementation -// that uses the underlying FileSystem's Read/Write methods -type BaseFileHandle struct { - id int64 - path string - flags OpenFlag - fs FileSystem - position int64 - closed bool -} - -// NewBaseFileHandle creates a new BaseFileHandle -func NewBaseFileHandle(id int64, path string, flags OpenFlag, fs FileSystem) *BaseFileHandle { - return &BaseFileHandle{ - id: id, - path: path, - flags: flags, - fs: fs, - position: 0, - closed: false, - } -} - -// ID returns the handle ID -func (h *BaseFileHandle) ID() int64 { - return h.id -} - -// Path returns the file path -func (h *BaseFileHandle) Path() string { - return h.path -} - -// Flags returns the open flags -func (h *BaseFileHandle) Flags() OpenFlag { - return h.flags -} - -// Read reads from the current position -func (h *BaseFileHandle) Read(buf []byte) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - if h.flags&O_WRONLY != 0 { - return 0, fmt.Errorf("handle not open for reading") - } - - data, err := h.fs.Read(h.path, h.position, int64(len(buf))) - if err != nil && err != io.EOF { - return 0, err - } - - n := copy(buf, data) - h.position += int64(n) - - if err == io.EOF { - return n, io.EOF - } - return n, nil -} - -// ReadAt reads from the specified offset -func (h *BaseFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - if h.flags&O_WRONLY != 0 { - return 0, fmt.Errorf("handle not open for reading") - } - - data, err := h.fs.Read(h.path, offset, int64(len(buf))) - if err != nil && err != io.EOF { - return 0, err - } - - n := copy(buf, data) - if err == io.EOF { - return n, io.EOF - } - return n, nil -} - -// Write writes at the current position -func (h *BaseFileHandle) Write(data []byte) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - if h.flags == O_RDONLY { - return 0, fmt.Errorf("handle not open for writing") - } - - var offset int64 = h.position - var flags WriteFlag = WriteFlagNone - - if h.flags&O_APPEND != 0 { - flags |= WriteFlagAppend - offset = -1 - } - - n, err := h.fs.Write(h.path, data, offset, flags) - if err != nil { - return 0, err - } - - if h.flags&O_APPEND == 0 { - h.position += n - } - - return int(n), nil -} - -// WriteAt writes at the specified offset -func (h *BaseFileHandle) WriteAt(data []byte, offset int64) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - if h.flags == O_RDONLY { - return 0, fmt.Errorf("handle not open for writing") - } - - n, err := h.fs.Write(h.path, data, offset, WriteFlagNone) - if err != nil { - return 0, err - } - - return int(n), nil -} - -// Seek changes the current position -func (h *BaseFileHandle) Seek(offset int64, whence int) (int64, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - - stat, err := h.fs.Stat(h.path) - if err != nil { - return 0, err - } - - var newPos int64 - switch whence { - case 0: // SEEK_SET - newPos = offset - case 1: // SEEK_CUR - newPos = h.position + offset - case 2: // SEEK_END - newPos = stat.Size + offset - default: - return 0, fmt.Errorf("invalid whence: %d", whence) - } - - if newPos < 0 { - return 0, fmt.Errorf("negative position: %d", newPos) - } - - h.position = newPos - return h.position, nil -} - -// Sync syncs the file -func (h *BaseFileHandle) Sync() error { - if h.closed { - return fmt.Errorf("handle is closed") - } - - if syncer, ok := h.fs.(Syncer); ok { - return syncer.Sync(h.path) - } - return nil -} - -// Close closes the handle -func (h *BaseFileHandle) Close() error { - if h.closed { - return nil - } - h.closed = true - return nil -} - -// Stat returns file information -func (h *BaseFileHandle) Stat() (*FileInfo, error) { - if h.closed { - return nil, fmt.Errorf("handle is closed") - } - return h.fs.Stat(h.path) -} - -// Ensure BaseFileHandle implements FileHandle -var _ FileHandle = (*BaseFileHandle)(nil) diff --git a/third_party/agfs/agfs-server/pkg/filesystem/capabilities.go b/third_party/agfs/agfs-server/pkg/filesystem/capabilities.go deleted file mode 100644 index 6f903c1f9..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/capabilities.go +++ /dev/null @@ -1,134 +0,0 @@ -package filesystem - -// Capabilities describes the features supported by a file system -type Capabilities struct { - // Basic capabilities - SupportsRandomWrite bool // Supports offset write (pwrite) - SupportsTruncate bool // Supports file truncation - SupportsSync bool // Supports sync/fsync - SupportsTouch bool // Supports efficient touch operation - SupportsFileHandle bool // Supports FileHandle interface - - // Special semantics - IsAppendOnly bool // Only supports append operations (e.g., QueueFS enqueue) - IsReadDestructive bool // Read has side effects (e.g., QueueFS dequeue) - IsObjectStore bool // Object store semantics, no offset write (e.g., S3FS) - IsBroadcast bool // Supports multiple reader fanout (e.g., StreamFS) - IsReadOnly bool // Read-only file system - - // Streaming capabilities - SupportsStreamRead bool // Supports streaming read (Streamer interface) - SupportsStreamWrite bool // Supports streaming write -} - -// CapabilityProvider is implemented by file systems that can report their capabilities -type CapabilityProvider interface { - // GetCapabilities returns the overall capabilities of the file system - GetCapabilities() Capabilities - - // GetPathCapabilities returns capabilities for a specific path - // Some paths may have different capabilities than the overall file system - // (e.g., QueueFS /queue/enqueue is append-only, /queue/dequeue is read-destructive) - GetPathCapabilities(path string) Capabilities -} - -// === Extension Interfaces === - -// RandomWriter is implemented by file systems that support random position writes -// This is required for efficient FUSE pwrite support -type RandomWriter interface { - // WriteAt writes data at the specified offset without affecting other parts of the file - // This is similar to POSIX pwrite - WriteAt(path string, data []byte, offset int64) (int64, error) -} - -// Truncater is implemented by file systems that support file truncation -type Truncater interface { - // Truncate changes the size of the file - // If size is less than current size, data is removed from the end - // If size is greater than current size, file is extended with zero bytes - Truncate(path string, size int64) error -} - -// Syncer is implemented by file systems that support data synchronization -type Syncer interface { - // Sync ensures all data for the file is written to persistent storage - Sync(path string) error -} - -// === Special Semantics Interfaces === - -// AppendOnlyFS marks file systems where certain paths only support append operations -// This is useful for queue-like services where data can only be added, not modified -type AppendOnlyFS interface { - // IsAppendOnly returns true if the specified path only supports append operations - IsAppendOnly(path string) bool -} - -// ReadDestructiveFS marks file systems where read operations have side effects -// This is useful for queue-like services where reading removes data -type ReadDestructiveFS interface { - // IsReadDestructive returns true if reading from the path has side effects - // (e.g., dequeue operation removes the message) - IsReadDestructive(path string) bool -} - -// ObjectStoreFS marks file systems with object store semantics -// Object stores typically don't support random writes or truncation -type ObjectStoreFS interface { - // IsObjectStore returns true if this is an object store (e.g., S3) - // Object stores require full object replacement for writes - IsObjectStore() bool -} - -// BroadcastFS marks file systems that support multiple reader fanout -// This is useful for streaming services where multiple clients receive the same data -type BroadcastFS interface { - // IsBroadcast returns true if the path supports broadcast/fanout to multiple readers - IsBroadcast(path string) bool -} - -// ReadOnlyFS marks file systems or paths that are read-only -type ReadOnlyFS interface { - // IsReadOnly returns true if the specified path is read-only - IsReadOnly(path string) bool -} - -// === Default Capabilities === - -// DefaultCapabilities returns a Capabilities struct with common defaults -// This represents a basic read/write file system without special features -func DefaultCapabilities() Capabilities { - return Capabilities{ - SupportsRandomWrite: false, - SupportsTruncate: false, - SupportsSync: false, - SupportsTouch: false, - SupportsFileHandle: false, - IsAppendOnly: false, - IsReadDestructive: false, - IsObjectStore: false, - IsBroadcast: false, - IsReadOnly: false, - SupportsStreamRead: false, - SupportsStreamWrite: false, - } -} - -// FullPOSIXCapabilities returns capabilities for a fully POSIX-compliant file system -func FullPOSIXCapabilities() Capabilities { - return Capabilities{ - SupportsRandomWrite: true, - SupportsTruncate: true, - SupportsSync: true, - SupportsTouch: true, - SupportsFileHandle: true, - IsAppendOnly: false, - IsReadDestructive: false, - IsObjectStore: false, - IsBroadcast: false, - IsReadOnly: false, - SupportsStreamRead: true, - SupportsStreamWrite: true, - } -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/errors.go b/third_party/agfs/agfs-server/pkg/filesystem/errors.go deleted file mode 100644 index 346721389..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/errors.go +++ /dev/null @@ -1,164 +0,0 @@ -package filesystem - -import ( - "errors" - "fmt" -) - -// Standard error types for filesystem operations -// These errors can be checked using errors.Is() for type-safe error handling - -var ( - // ErrNotFound indicates a file or directory does not exist - ErrNotFound = errors.New("not found") - - // ErrPermissionDenied indicates insufficient permissions for the operation - ErrPermissionDenied = errors.New("permission denied") - - // ErrInvalidArgument indicates an invalid argument was provided - ErrInvalidArgument = errors.New("invalid argument") - - // ErrAlreadyExists indicates a resource already exists (conflict) - ErrAlreadyExists = errors.New("already exists") - - // ErrNotDirectory indicates the path is not a directory when one was expected - ErrNotDirectory = errors.New("not a directory") - - // ErrNotSupported indicates the operation is not supported by this filesystem - ErrNotSupported = errors.New("operation not supported") -) - -// NotFoundError represents a file or directory not found error with context -type NotFoundError struct { - Path string - Op string // Operation that failed (e.g., "read", "stat", "readdir") -} - -func (e *NotFoundError) Error() string { - if e.Op != "" { - return fmt.Sprintf("%s: %s: %s", e.Op, e.Path, "not found") - } - return fmt.Sprintf("%s: not found", e.Path) -} - -func (e *NotFoundError) Is(target error) bool { - return target == ErrNotFound -} - -// PermissionDeniedError represents a permission error with context -type PermissionDeniedError struct { - Path string - Op string - Reason string // Optional reason (e.g., "write-only file") -} - -func (e *PermissionDeniedError) Error() string { - if e.Reason != "" { - return fmt.Sprintf("%s: %s: permission denied (%s)", e.Op, e.Path, e.Reason) - } - if e.Op != "" { - return fmt.Sprintf("%s: %s: permission denied", e.Op, e.Path) - } - return fmt.Sprintf("%s: permission denied", e.Path) -} - -func (e *PermissionDeniedError) Is(target error) bool { - return target == ErrPermissionDenied -} - -// InvalidArgumentError represents an invalid argument error with context -type InvalidArgumentError struct { - Name string // Name of the argument - Value interface{} - Reason string -} - -func (e *InvalidArgumentError) Error() string { - if e.Value != nil { - return fmt.Sprintf("invalid argument %s=%v: %s", e.Name, e.Value, e.Reason) - } - return fmt.Sprintf("invalid argument %s: %s", e.Name, e.Reason) -} - -func (e *InvalidArgumentError) Is(target error) bool { - return target == ErrInvalidArgument -} - -// AlreadyExistsError represents a resource conflict error -type AlreadyExistsError struct { - Path string - Resource string // Type of resource (e.g., "mount", "file", "directory") -} - -func (e *AlreadyExistsError) Error() string { - if e.Resource != "" { - return fmt.Sprintf("%s already exists: %s", e.Resource, e.Path) - } - return fmt.Sprintf("already exists: %s", e.Path) -} - -func (e *AlreadyExistsError) Is(target error) bool { - return target == ErrAlreadyExists -} - -// NotDirectoryError represents an error when a directory was expected but the path is not a directory -type NotDirectoryError struct { - Path string -} - -func (e *NotDirectoryError) Error() string { - return fmt.Sprintf("not a directory: %s", e.Path) -} - -func (e *NotDirectoryError) Is(target error) bool { - return target == ErrNotDirectory -} - -// NotSupportedError represents an error when an operation is not supported by the filesystem -type NotSupportedError struct { - Path string - Op string // Operation that failed (e.g., "openhandle", "stream") -} - -func (e *NotSupportedError) Error() string { - if e.Op != "" { - return fmt.Sprintf("%s: %s: operation not supported", e.Op, e.Path) - } - return fmt.Sprintf("%s: operation not supported", e.Path) -} - -func (e *NotSupportedError) Is(target error) bool { - return target == ErrNotSupported -} - -// Helper functions to create common errors - -// NewNotFoundError creates a new NotFoundError -func NewNotFoundError(op, path string) error { - return &NotFoundError{Op: op, Path: path} -} - -// NewPermissionDeniedError creates a new PermissionDeniedError -func NewPermissionDeniedError(op, path, reason string) error { - return &PermissionDeniedError{Op: op, Path: path, Reason: reason} -} - -// NewInvalidArgumentError creates a new InvalidArgumentError -func NewInvalidArgumentError(name string, value interface{}, reason string) error { - return &InvalidArgumentError{Name: name, Value: value, Reason: reason} -} - -// NewAlreadyExistsError creates a new AlreadyExistsError -func NewAlreadyExistsError(resource, path string) error { - return &AlreadyExistsError{Resource: resource, Path: path} -} - -// NewNotDirectoryError creates a new NotDirectoryError -func NewNotDirectoryError(path string) error { - return &NotDirectoryError{Path: path} -} - -// NewNotSupportedError creates a new NotSupportedError -func NewNotSupportedError(op, path string) error { - return &NotSupportedError{Op: op, Path: path} -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/filesystem.go b/third_party/agfs/agfs-server/pkg/filesystem/filesystem.go deleted file mode 100644 index 06b02fed5..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/filesystem.go +++ /dev/null @@ -1,137 +0,0 @@ -package filesystem - -import ( - "io" - "time" -) - -// WriteFlag defines write behavior flags (similar to POSIX open flags) -type WriteFlag uint32 - -const ( - // WriteFlagNone is the default behavior: overwrite the file - WriteFlagNone WriteFlag = 0 - - // WriteFlagAppend appends data to the end of the file (ignores offset) - WriteFlagAppend WriteFlag = 1 << 0 - - // WriteFlagCreate creates the file if it doesn't exist - WriteFlagCreate WriteFlag = 1 << 1 - - // WriteFlagExclusive fails if the file already exists (used with WriteFlagCreate) - WriteFlagExclusive WriteFlag = 1 << 2 - - // WriteFlagTruncate truncates the file before writing - WriteFlagTruncate WriteFlag = 1 << 3 - - // WriteFlagSync syncs the file after writing (fsync) - WriteFlagSync WriteFlag = 1 << 4 -) - -// OpenFlag defines file open flags (similar to os.O_* flags) -type OpenFlag int - -const ( - O_RDONLY OpenFlag = 0 - O_WRONLY OpenFlag = 1 - O_RDWR OpenFlag = 2 - O_APPEND OpenFlag = 1 << 3 - O_CREATE OpenFlag = 1 << 4 - O_EXCL OpenFlag = 1 << 5 - O_TRUNC OpenFlag = 1 << 6 -) - -// MetaData represents structured metadata for files and directories -type MetaData struct { - Name string // Plugin name or identifier - Type string // Type classification of the file/directory - Content map[string]string // Additional extensible metadata -} - -// FileInfo represents file metadata similar to os.FileInfo -type FileInfo struct { - Name string - Size int64 - Mode uint32 - ModTime time.Time - IsDir bool - Meta MetaData // Structured metadata for additional information -} - -// FileSystem defines the interface for a POSIX-like file system -type FileSystem interface { - // Create creates a new file - Create(path string) error - - // Mkdir creates a new directory - Mkdir(path string, perm uint32) error - - // Remove removes a file or empty directory - Remove(path string) error - - // RemoveAll removes a path and any children it contains - RemoveAll(path string) error - - // Read reads file content with optional offset and size - // offset: starting position (0 means from beginning) - // size: number of bytes to read (-1 means read all) - // Returns io.EOF if offset+size >= file size (reached end of file) - Read(path string, offset int64, size int64) ([]byte, error) - - // Write writes data to a file with optional offset and flags - // offset: write position (-1 means overwrite or append depending on flags) - // flags: WriteFlag bits controlling behavior (create, truncate, append, sync) - // Returns: number of bytes written and error - Write(path string, data []byte, offset int64, flags WriteFlag) (int64, error) - - // ReadDir lists the contents of a directory - ReadDir(path string) ([]FileInfo, error) - - // Stat returns file information - Stat(path string) (*FileInfo, error) - - // Rename renames/moves a file or directory - Rename(oldPath, newPath string) error - - // Chmod changes file permissions - Chmod(path string, mode uint32) error - - // Open opens a file for reading - Open(path string) (io.ReadCloser, error) - - // OpenWrite opens a file for writing - OpenWrite(path string) (io.WriteCloser, error) -} - -// StreamReader represents a readable stream with support for chunked reads -// This interface is used by streaming file systems (e.g., streamfs) to provide -// real-time data streaming with fanout capability -type StreamReader interface { - // ReadChunk reads the next chunk of data with a timeout - // Returns (data, isEOF, error) - // - data: the chunk data (may be nil if timeout or EOF) - // - isEOF: true if stream is closed/ended - // - error: io.EOF for normal stream end, "read timeout" for timeout, or other errors - ReadChunk(timeout time.Duration) ([]byte, bool, error) - - // Close closes this reader and releases associated resources - Close() error -} - -// Streamer is implemented by file systems that support streaming reads -// Streaming allows multiple readers to consume data in real-time as it's written -type Streamer interface { - // OpenStream opens a stream for reading - // Returns a StreamReader that can read chunks progressively - // Multiple readers can open the same stream for fanout/broadcast scenarios - OpenStream(path string) (StreamReader, error) -} - -// Toucher is implemented by file systems that support efficient touch operations -// Touch updates the modification time without reading/writing the entire file content -type Toucher interface { - // Touch updates the modification time of a file - // If the file doesn't exist, it should be created as an empty file - // Returns error if the operation fails - Touch(path string) error -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/filesystem_test.go b/third_party/agfs/agfs-server/pkg/filesystem/filesystem_test.go deleted file mode 100644 index 7f55c3ef8..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/filesystem_test.go +++ /dev/null @@ -1,251 +0,0 @@ -package filesystem - -import ( - "testing" -) - -func TestWriteFlag(t *testing.T) { - tests := []struct { - name string - flags WriteFlag - expected map[string]bool - }{ - { - name: "None flag", - flags: WriteFlagNone, - expected: map[string]bool{ - "append": false, - "create": false, - "exclusive": false, - "truncate": false, - "sync": false, - }, - }, - { - name: "Append flag", - flags: WriteFlagAppend, - expected: map[string]bool{ - "append": true, - "create": false, - "exclusive": false, - "truncate": false, - "sync": false, - }, - }, - { - name: "Create and Truncate flags", - flags: WriteFlagCreate | WriteFlagTruncate, - expected: map[string]bool{ - "append": false, - "create": true, - "exclusive": false, - "truncate": true, - "sync": false, - }, - }, - { - name: "All flags", - flags: WriteFlagAppend | WriteFlagCreate | WriteFlagExclusive | WriteFlagTruncate | WriteFlagSync, - expected: map[string]bool{ - "append": true, - "create": true, - "exclusive": true, - "truncate": true, - "sync": true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := (tt.flags & WriteFlagAppend) != 0; got != tt.expected["append"] { - t.Errorf("Append flag: got %v, want %v", got, tt.expected["append"]) - } - if got := (tt.flags & WriteFlagCreate) != 0; got != tt.expected["create"] { - t.Errorf("Create flag: got %v, want %v", got, tt.expected["create"]) - } - if got := (tt.flags & WriteFlagExclusive) != 0; got != tt.expected["exclusive"] { - t.Errorf("Exclusive flag: got %v, want %v", got, tt.expected["exclusive"]) - } - if got := (tt.flags & WriteFlagTruncate) != 0; got != tt.expected["truncate"] { - t.Errorf("Truncate flag: got %v, want %v", got, tt.expected["truncate"]) - } - if got := (tt.flags & WriteFlagSync) != 0; got != tt.expected["sync"] { - t.Errorf("Sync flag: got %v, want %v", got, tt.expected["sync"]) - } - }) - } -} - -func TestWriteFlagValues(t *testing.T) { - // Ensure flags have correct bit values - if WriteFlagNone != 0 { - t.Errorf("WriteFlagNone should be 0, got %d", WriteFlagNone) - } - if WriteFlagAppend != 1 { - t.Errorf("WriteFlagAppend should be 1, got %d", WriteFlagAppend) - } - if WriteFlagCreate != 2 { - t.Errorf("WriteFlagCreate should be 2, got %d", WriteFlagCreate) - } - if WriteFlagExclusive != 4 { - t.Errorf("WriteFlagExclusive should be 4, got %d", WriteFlagExclusive) - } - if WriteFlagTruncate != 8 { - t.Errorf("WriteFlagTruncate should be 8, got %d", WriteFlagTruncate) - } - if WriteFlagSync != 16 { - t.Errorf("WriteFlagSync should be 16, got %d", WriteFlagSync) - } -} - -func TestOpenFlag(t *testing.T) { - tests := []struct { - name string - flags OpenFlag - expected map[string]bool - }{ - { - name: "Read only", - flags: O_RDONLY, - expected: map[string]bool{ - "read": true, - "write": false, - "rdwr": false, - "append": false, - "create": false, - "excl": false, - "truncate": false, - }, - }, - { - name: "Write only", - flags: O_WRONLY, - expected: map[string]bool{ - "read": false, - "write": true, - "rdwr": false, - "append": false, - "create": false, - "excl": false, - "truncate": false, - }, - }, - { - name: "Read/Write with Create and Truncate", - flags: O_RDWR | O_CREATE | O_TRUNC, - expected: map[string]bool{ - "read": false, - "write": false, - "rdwr": true, - "append": false, - "create": true, - "excl": false, - "truncate": true, - }, - }, - { - name: "Write with Append and Create", - flags: O_WRONLY | O_APPEND | O_CREATE, - expected: map[string]bool{ - "read": false, - "write": true, - "rdwr": false, - "append": true, - "create": true, - "excl": false, - "truncate": false, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Check access mode (lower 2 bits) - accessMode := tt.flags & 0x3 - if tt.expected["rdwr"] && accessMode != O_RDWR { - t.Errorf("Expected O_RDWR mode") - } - if tt.expected["write"] && accessMode != O_WRONLY { - t.Errorf("Expected O_WRONLY mode") - } - if tt.expected["read"] && accessMode != O_RDONLY { - t.Errorf("Expected O_RDONLY mode") - } - - // Check flag bits - if got := (tt.flags & O_APPEND) != 0; got != tt.expected["append"] { - t.Errorf("O_APPEND: got %v, want %v", got, tt.expected["append"]) - } - if got := (tt.flags & O_CREATE) != 0; got != tt.expected["create"] { - t.Errorf("O_CREATE: got %v, want %v", got, tt.expected["create"]) - } - if got := (tt.flags & O_EXCL) != 0; got != tt.expected["excl"] { - t.Errorf("O_EXCL: got %v, want %v", got, tt.expected["excl"]) - } - if got := (tt.flags & O_TRUNC) != 0; got != tt.expected["truncate"] { - t.Errorf("O_TRUNC: got %v, want %v", got, tt.expected["truncate"]) - } - }) - } -} - -func TestOpenFlagValues(t *testing.T) { - // Ensure flags have correct values matching POSIX conventions - if O_RDONLY != 0 { - t.Errorf("O_RDONLY should be 0, got %d", O_RDONLY) - } - if O_WRONLY != 1 { - t.Errorf("O_WRONLY should be 1, got %d", O_WRONLY) - } - if O_RDWR != 2 { - t.Errorf("O_RDWR should be 2, got %d", O_RDWR) - } - if O_APPEND != 8 { - t.Errorf("O_APPEND should be 8, got %d", O_APPEND) - } - if O_CREATE != 16 { - t.Errorf("O_CREATE should be 16, got %d", O_CREATE) - } - if O_EXCL != 32 { - t.Errorf("O_EXCL should be 32, got %d", O_EXCL) - } - if O_TRUNC != 64 { - t.Errorf("O_TRUNC should be 64, got %d", O_TRUNC) - } -} - -func TestFileInfo(t *testing.T) { - info := FileInfo{ - Name: "test.txt", - Size: 1024, - Mode: 0644, - IsDir: false, - Meta: MetaData{ - Name: "memfs", - Type: "file", - Content: map[string]string{ - "key": "value", - }, - }, - } - - if info.Name != "test.txt" { - t.Errorf("Name: got %s, want test.txt", info.Name) - } - if info.Size != 1024 { - t.Errorf("Size: got %d, want 1024", info.Size) - } - if info.Mode != 0644 { - t.Errorf("Mode: got %o, want 644", info.Mode) - } - if info.IsDir { - t.Error("IsDir should be false") - } - if info.Meta.Name != "memfs" { - t.Errorf("Meta.Name: got %s, want memfs", info.Meta.Name) - } - if info.Meta.Content["key"] != "value" { - t.Errorf("Meta.Content[key]: got %s, want value", info.Meta.Content["key"]) - } -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/handle.go b/third_party/agfs/agfs-server/pkg/filesystem/handle.go deleted file mode 100644 index aceb85f84..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/handle.go +++ /dev/null @@ -1,61 +0,0 @@ -package filesystem - -// FileHandle represents an open file handle with stateful operations -// This interface is used for FUSE-like operations that require maintaining -// file position and state across multiple read/write operations -type FileHandle interface { - // ID returns the unique identifier of this handle (used for REST API) - ID() int64 - - // Path returns the file path this handle is associated with - Path() string - - // Read reads up to len(buf) bytes from the current position - Read(buf []byte) (int, error) - - // ReadAt reads len(buf) bytes from the specified offset (pread) - ReadAt(buf []byte, offset int64) (int, error) - - // Write writes data at the current position - Write(data []byte) (int, error) - - // WriteAt writes data at the specified offset (pwrite) - WriteAt(data []byte, offset int64) (int, error) - - // Seek moves the read/write position - // whence: 0 = SEEK_SET (from start), 1 = SEEK_CUR (from current), 2 = SEEK_END (from end) - Seek(offset int64, whence int) (int64, error) - - // Sync synchronizes the file data to storage - Sync() error - - // Close closes the handle and releases resources - Close() error - - // Stat returns file information - Stat() (*FileInfo, error) - - // Flags returns the open flags used when opening this handle - Flags() OpenFlag -} - -// HandleFS is implemented by file systems that support stateful file handles -// This is optional - file systems that don't support handles can still work -// with the basic FileSystem interface -type HandleFS interface { - FileSystem - - // OpenHandle opens a file and returns a handle for stateful operations - // flags: OpenFlag bits (O_RDONLY, O_WRONLY, O_RDWR, O_APPEND, O_CREATE, O_EXCL, O_TRUNC) - // mode: file permission mode (used when creating new files) - OpenHandle(path string, flags OpenFlag, mode uint32) (FileHandle, error) - - // GetHandle retrieves an existing handle by its ID - // Returns ErrNotFound if the handle doesn't exist or has expired - GetHandle(id int64) (FileHandle, error) - - // CloseHandle closes a handle by its ID - // This is equivalent to calling handle.Close() but can be used when - // only the ID is available (e.g., from REST API) - CloseHandle(id int64) error -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/pathutil.go b/third_party/agfs/agfs-server/pkg/filesystem/pathutil.go deleted file mode 100644 index d5c37d2a7..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/pathutil.go +++ /dev/null @@ -1,68 +0,0 @@ -package filesystem - -import ( - "path" - "strings" -) - -// NormalizePath normalizes a filesystem path to a canonical form. -// - Empty paths and "/" return "/" -// - Adds leading "/" if missing -// - Cleans the path (removes .., ., etc.) -// - Removes trailing slashes (except for root "/") -// -// This is used by most filesystem implementations (memfs, sqlfs, httpfs, etc.) -func NormalizePath(p string) string { - if p == "" || p == "/" { - return "/" - } - - // Ensure leading slash - if !strings.HasPrefix(p, "/") { - p = "/" + p - } - - // Clean the path (resolve .., ., etc.) - // Use path.Clean instead of filepath.Clean to ensure consistency across OS - // and always use forward slashes for VFS paths - p = path.Clean(p) - - // path.Clean can return "." for some inputs - if p == "." { - return "/" - } - - // Remove trailing slash (Clean might leave it in some cases) - if len(p) > 1 && strings.HasSuffix(p, "/") { - p = p[:len(p)-1] - } - - return p -} - -// NormalizeS3Key normalizes an S3 object key. -// S3 keys don't have a leading slash, so this: -// - Returns "" for empty paths or "/" -// - Removes leading "/" -// - Cleans the path -// -// This is used specifically by s3fs plugin. -func NormalizeS3Key(p string) string { - if p == "" || p == "/" { - return "" - } - - // Remove leading slash (S3 keys don't have them) - p = strings.TrimPrefix(p, "/") - - // Clean the path - // Use path.Clean instead of filepath.Clean - p = path.Clean(p) - - // path.Clean returns "." for empty/root paths - if p == "." { - return "" - } - - return p -} diff --git a/third_party/agfs/agfs-server/pkg/filesystem/writer.go b/third_party/agfs/agfs-server/pkg/filesystem/writer.go deleted file mode 100644 index 921203c82..000000000 --- a/third_party/agfs/agfs-server/pkg/filesystem/writer.go +++ /dev/null @@ -1,42 +0,0 @@ -package filesystem - -import "io" - -// WriteFunc is a function that writes data to a path and returns the bytes written and any error. -// This is typically a FileSystem's Write method. -type WriteFunc func(path string, data []byte, offset int64, flags WriteFlag) (int64, error) - -// BufferedWriter is a generic io.WriteCloser that buffers writes in memory -// and flushes them when Close() is called. -// This is useful for filesystem implementations that don't support streaming writes. -type BufferedWriter struct { - path string - buf []byte - writeFunc WriteFunc -} - -// NewBufferedWriter creates a new BufferedWriter that will write to the given path -// using the provided write function when Close() is called. -func NewBufferedWriter(path string, writeFunc WriteFunc) *BufferedWriter { - return &BufferedWriter{ - path: path, - buf: make([]byte, 0), - writeFunc: writeFunc, - } -} - -// Write appends data to the internal buffer. -// It never returns an error, following the io.Writer contract. -func (w *BufferedWriter) Write(p []byte) (n int, err error) { - w.buf = append(w.buf, p...) - return len(p), nil -} - -// Close flushes the buffered data by calling the write function and returns any error. -func (w *BufferedWriter) Close() error { - _, err := w.writeFunc(w.path, w.buf, -1, WriteFlagCreate|WriteFlagTruncate) - return err -} - -// Ensure BufferedWriter implements io.WriteCloser -var _ io.WriteCloser = (*BufferedWriter)(nil) diff --git a/third_party/agfs/agfs-server/pkg/handlers/handle_handlers.go b/third_party/agfs/agfs-server/pkg/handlers/handle_handlers.go deleted file mode 100644 index 0d7058dfa..000000000 --- a/third_party/agfs/agfs-server/pkg/handlers/handle_handlers.go +++ /dev/null @@ -1,641 +0,0 @@ -package handlers - -import ( - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -// HandleOpenRequest represents the request to open a file handle -type HandleOpenRequest struct { - Path string `json:"path"` - Flags int `json:"flags"` // Numeric flags: 0=O_RDONLY, 1=O_WRONLY, 2=O_RDWR, etc. - Mode uint32 `json:"mode"` // File mode for creation (octal) -} - -// HandleOpenResponse represents the response when opening a handle -type HandleOpenResponse struct { - HandleID int64 `json:"handle_id"` - Path string `json:"path"` - Flags int `json:"flags"` - Lease int `json:"lease"` // Lease duration in seconds - ExpiresAt time.Time `json:"expires_at"` // When the lease expires -} - -// HandleInfoResponse represents handle information -type HandleInfoResponse struct { - HandleID int64 `json:"handle_id"` - Path string `json:"path"` - Flags int `json:"flags"` - Lease int `json:"lease"` - ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` - LastAccess time.Time `json:"last_access"` -} - -// HandleListResponse represents the list of active handles -type HandleListResponse struct { - Handles []HandleInfoResponse `json:"handles"` - Count int `json:"count"` - Max int `json:"max"` -} - -// HandleReadResponse represents the response for read operations -type HandleReadResponse struct { - BytesRead int `json:"bytes_read"` - Position int64 `json:"position"` // Current position after read -} - -// HandleWriteResponse represents the response for write operations -type HandleWriteResponse struct { - BytesWritten int `json:"bytes_written"` - Position int64 `json:"position"` // Current position after write -} - -// HandleSeekResponse represents the response for seek operations -type HandleSeekResponse struct { - Position int64 `json:"position"` -} - -// HandleRenewResponse represents the response for lease renewal -type HandleRenewResponse struct { - ExpiresAt time.Time `json:"expires_at"` - Lease int `json:"lease"` -} - -// parseOpenFlags parses numeric flag parameter to OpenFlag -func parseOpenFlags(flagStr string) (filesystem.OpenFlag, error) { - if flagStr == "" { - return filesystem.O_RDONLY, nil - } - - num, err := strconv.ParseInt(flagStr, 10, 32) - if err != nil { - return 0, fmt.Errorf("invalid flags parameter: must be a number") - } - return filesystem.OpenFlag(num), nil -} - - -// getHandleFS checks if the filesystem supports HandleFS and returns it -func (h *Handler) getHandleFS() (filesystem.HandleFS, error) { - handleFS, ok := h.fs.(filesystem.HandleFS) - if !ok { - return nil, fmt.Errorf("filesystem does not support file handles") - } - return handleFS, nil -} - -// OpenHandle handles POST /api/v1/handles/open?path=<path>&flags=<flags>&mode=<mode> -func (h *Handler) OpenHandle(w http.ResponseWriter, r *http.Request) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - flagStr := r.URL.Query().Get("flags") - flags, err := parseOpenFlags(flagStr) - if err != nil { - writeError(w, http.StatusBadRequest, err.Error()) - return - } - - modeStr := r.URL.Query().Get("mode") - mode := uint32(0644) - if modeStr != "" { - m, err := strconv.ParseUint(modeStr, 8, 32) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid mode parameter") - return - } - mode = uint32(m) - } - - handle, err := handleFS.OpenHandle(path, flags, mode) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - // Handle opened successfully - response := HandleOpenResponse{ - HandleID: handle.ID(), - Path: handle.Path(), - Flags: int(handle.Flags()), - Lease: 60, - ExpiresAt: time.Now().Add(60 * time.Second), - } - - writeJSON(w, http.StatusOK, response) -} - -// GetHandle handles GET /api/v1/handles/<id> -func (h *Handler) GetHandle(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - response := HandleInfoResponse{ - HandleID: handle.ID(), - Path: handle.Path(), - Flags: int(handle.Flags()), - Lease: 60, - ExpiresAt: time.Now().Add(60 * time.Second), - CreatedAt: time.Now(), // Placeholder - actual implementation would track this - LastAccess: time.Now(), - } - - writeJSON(w, http.StatusOK, response) -} - -// CloseHandle handles DELETE /api/v1/handles/<id> -func (h *Handler) CloseHandle(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - if err := handleFS.CloseHandle(handleID); err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "handle closed"}) -} - -// HandleRead handles GET /api/v1/handles/<id>/read?offset=<offset>&size=<size> -func (h *Handler) HandleRead(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - // Parse size parameter (required for read) - sizeStr := r.URL.Query().Get("size") - size := int64(4096) // Default read size - if sizeStr != "" { - s, err := strconv.ParseInt(sizeStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid size parameter") - return - } - if s < 0 { - // -1 means read all, use a reasonable default - size = 1024 * 1024 // 1MB max for "read all" - } else { - size = s - } - } - - // Check if offset is specified (use ReadAt) - offsetStr := r.URL.Query().Get("offset") - var data []byte - var n int - - if offsetStr != "" { - offset, err := strconv.ParseInt(offsetStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid offset parameter") - return - } - buf := make([]byte, size) - n, err = handle.ReadAt(buf, offset) - if err != nil && err != io.EOF { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - data = buf[:n] - } else { - buf := make([]byte, size) - n, err = handle.Read(buf) - if err != nil && err != io.EOF { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - data = buf[:n] - } - - // Record traffic - if h.trafficMonitor != nil && n > 0 { - h.trafficMonitor.RecordRead(int64(n)) - } - - // Return binary data - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("X-Bytes-Read", strconv.Itoa(n)) - w.WriteHeader(http.StatusOK) - w.Write(data) -} - -// HandleWrite handles PUT /api/v1/handles/<id>/write?offset=<offset> -func (h *Handler) HandleWrite(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - data, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - - // Record traffic - if h.trafficMonitor != nil && len(data) > 0 { - h.trafficMonitor.RecordWrite(int64(len(data))) - } - - var n int - - // Check if offset is specified (use WriteAt) - offsetStr := r.URL.Query().Get("offset") - if offsetStr != "" { - offset, err := strconv.ParseInt(offsetStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid offset parameter") - return - } - n, err = handle.WriteAt(data, offset) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - } else { - n, err = handle.Write(data) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - } - - response := HandleWriteResponse{ - BytesWritten: n, - } - writeJSON(w, http.StatusOK, response) -} - -// HandleSeek handles POST /api/v1/handles/<id>/seek?offset=<offset>&whence=<0|1|2> -func (h *Handler) HandleSeek(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - offsetStr := r.URL.Query().Get("offset") - if offsetStr == "" { - writeError(w, http.StatusBadRequest, "offset parameter is required") - return - } - offset, err := strconv.ParseInt(offsetStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid offset parameter") - return - } - - whenceStr := r.URL.Query().Get("whence") - whence := io.SeekStart // Default - if whenceStr != "" { - wh, err := strconv.Atoi(whenceStr) - if err != nil || wh < 0 || wh > 2 { - writeError(w, http.StatusBadRequest, "invalid whence parameter (must be 0, 1, or 2)") - return - } - whence = wh - } - - pos, err := handle.Seek(offset, whence) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - response := HandleSeekResponse{ - Position: pos, - } - writeJSON(w, http.StatusOK, response) -} - -// HandleSync handles POST /api/v1/handles/<id>/sync -func (h *Handler) HandleSync(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - if err := handle.Sync(); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "synced"}) -} - -// HandleStat handles GET /api/v1/handles/<id>/stat -func (h *Handler) HandleStat(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - info, err := handle.Stat() - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - response := FileInfoResponse{ - Name: info.Name, - Size: info.Size, - Mode: info.Mode, - ModTime: info.ModTime.Format(time.RFC3339Nano), - IsDir: info.IsDir, - Meta: info.Meta, - } - - writeJSON(w, http.StatusOK, response) -} - -// HandleStream handles GET /api/v1/handles/<id>/stream - streaming read -// Uses chunked transfer encoding for continuous data streaming -func (h *Handler) HandleStream(w http.ResponseWriter, r *http.Request, handleIDStr string) { - handleFS, err := h.getHandleFS() - if err != nil { - writeError(w, http.StatusNotImplemented, err.Error()) - return - } - - handleID, err := strconv.ParseInt(handleIDStr, 10, 64) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid handle ID: must be a number") - return - } - - handle, err := handleFS.GetHandle(handleID) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - // Set headers for streaming - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Transfer-Encoding", "chunked") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(http.StatusOK) - - // Get flusher for streaming - flusher, ok := w.(http.Flusher) - if !ok { - writeError(w, http.StatusInternalServerError, "streaming not supported") - return - } - - // Read and stream data - buf := make([]byte, 64*1024) // 64KB buffer - for { - n, err := handle.Read(buf) - if n > 0 { - _, writeErr := w.Write(buf[:n]) - if writeErr != nil { - // Client disconnected - return - } - flusher.Flush() - - // Record traffic - if h.trafficMonitor != nil { - h.trafficMonitor.RecordRead(int64(n)) - } - } - - if err == io.EOF { - // Stream ended - return - } - if err != nil { - // Error reading - just return, client will see connection close - return - } - - // Check if client disconnected - select { - case <-r.Context().Done(): - return - default: - } - } -} - -// SetupHandleRoutes sets up routes for file handle operations -func (h *Handler) SetupHandleRoutes(mux *http.ServeMux) { - // POST /api/v1/handles/open - Open a new handle - mux.HandleFunc("/api/v1/handles/open", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.OpenHandle(w, r) - }) - - // Handle operations on specific handles: /api/v1/handles/<id>/* - mux.HandleFunc("/api/v1/handles/", func(w http.ResponseWriter, r *http.Request) { - // Extract handle ID and operation from path - // Path format: /api/v1/handles/<id> or /api/v1/handles/<id>/<operation> - path := strings.TrimPrefix(r.URL.Path, "/api/v1/handles/") - - // Skip if this is the /open endpoint (handled separately) - if path == "open" || strings.HasPrefix(path, "open?") { - return - } - - parts := strings.SplitN(path, "/", 2) - if len(parts) == 0 || parts[0] == "" { - // List all handles: GET /api/v1/handles/ - if r.Method == http.MethodGet { - h.ListHandles(w, r) - return - } - writeError(w, http.StatusBadRequest, "handle ID required") - return - } - - handleID := parts[0] - operation := "" - if len(parts) > 1 { - operation = parts[1] - } - - // Route based on operation - switch operation { - case "": - // Operations on the handle itself - switch r.Method { - case http.MethodGet: - h.GetHandle(w, r, handleID) - case http.MethodDelete: - h.CloseHandle(w, r, handleID) - default: - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - } - case "read": - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.HandleRead(w, r, handleID) - case "write": - if r.Method != http.MethodPut { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.HandleWrite(w, r, handleID) - case "seek": - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.HandleSeek(w, r, handleID) - case "sync": - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.HandleSync(w, r, handleID) - case "stat": - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.HandleStat(w, r, handleID) - case "stream": - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.HandleStream(w, r, handleID) - default: - writeError(w, http.StatusNotFound, "unknown operation: "+operation) - } - }) -} - -// ListHandles handles GET /api/v1/handles - list all active handles -// Note: This returns an empty list as handles are managed per-request -// and there is no central registry. Handles are tracked within each -// mounted filesystem instance. -func (h *Handler) ListHandles(w http.ResponseWriter, r *http.Request) { - // Return empty list - handles are managed by individual filesystem instances - response := HandleListResponse{ - Handles: []HandleInfoResponse{}, - Count: 0, - Max: 10000, - } - writeJSON(w, http.StatusOK, response) -} diff --git a/third_party/agfs/agfs-server/pkg/handlers/handlers.go b/third_party/agfs/agfs-server/pkg/handlers/handlers.go deleted file mode 100644 index 99f570e8c..000000000 --- a/third_party/agfs/agfs-server/pkg/handlers/handlers.go +++ /dev/null @@ -1,1380 +0,0 @@ -package handlers - -import ( - "bufio" - "bytes" - "crypto/md5" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os/exec" - "path" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" - log "github.com/sirupsen/logrus" - "github.com/zeebo/xxh3" -) - -// Handler wraps the FileSystem and provides HTTP handlers -type Handler struct { - fs filesystem.FileSystem - version string - gitCommit string - buildTime string - trafficMonitor *TrafficMonitor -} - -// NewHandler creates a new Handler -func NewHandler(fs filesystem.FileSystem, trafficMonitor *TrafficMonitor) *Handler { - return &Handler{ - fs: fs, - version: "dev", - gitCommit: "unknown", - buildTime: "unknown", - trafficMonitor: trafficMonitor, - } -} - -// SetVersionInfo sets the version information for the handler -func (h *Handler) SetVersionInfo(version, gitCommit, buildTime string) { - h.version = version - h.gitCommit = gitCommit - h.buildTime = buildTime -} - -// ErrorResponse represents an error response -type ErrorResponse struct { - Error string `json:"error"` -} - -// SuccessResponse represents a success response -type SuccessResponse struct { - Message string `json:"message"` -} - -// FileInfoResponse represents file info response -type FileInfoResponse struct { - Name string `json:"name"` - Size int64 `json:"size"` - Mode uint32 `json:"mode"` - ModTime string `json:"modTime"` - IsDir bool `json:"isDir"` - Meta filesystem.MetaData `json:"meta,omitempty"` // Structured metadata -} - -// ListResponse represents directory listing response -type ListResponse struct { - Files []FileInfoResponse `json:"files"` -} - -// WriteRequest represents a write request -type WriteRequest struct { - Data string `json:"data"` -} - -// RenameRequest represents a rename request -type RenameRequest struct { - NewPath string `json:"newPath"` -} - -// ChmodRequest represents a chmod request -type ChmodRequest struct { - Mode uint32 `json:"mode"` -} - -// DigestRequest represents a digest request -type DigestRequest struct { - Algorithm string `json:"algorithm"` // "xxh3" or "md5" - Path string `json:"path"` // Path to the file -} - -// DigestResponse represents the digest result -type DigestResponse struct { - Algorithm string `json:"algorithm"` // Algorithm used - Path string `json:"path"` // File path - Digest string `json:"digest"` // Hex-encoded digest -} - -func writeJSON(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(data) -} - -func writeError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, ErrorResponse{Error: message}) -} - -// mapErrorToStatus maps filesystem errors to HTTP status codes -func mapErrorToStatus(err error) int { - if errors.Is(err, filesystem.ErrNotFound) { - return http.StatusNotFound - } - if errors.Is(err, filesystem.ErrPermissionDenied) { - return http.StatusForbidden - } - if errors.Is(err, filesystem.ErrInvalidArgument) { - return http.StatusBadRequest - } - if errors.Is(err, filesystem.ErrAlreadyExists) { - return http.StatusConflict - } - if errors.Is(err, filesystem.ErrNotSupported) { - return http.StatusNotImplemented - } - return http.StatusInternalServerError -} - -// CreateFile handles POST /files?path=<path> -func (h *Handler) CreateFile(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - if err := h.fs.Create(path); err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - writeJSON(w, http.StatusCreated, SuccessResponse{Message: "file created"}) -} - -// CreateDirectory handles POST /directories?path=<path>&mode=<mode> -func (h *Handler) CreateDirectory(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - modeStr := r.URL.Query().Get("mode") - mode := uint32(0755) - if modeStr != "" { - m, err := strconv.ParseUint(modeStr, 8, 32) - if err != nil { - writeError(w, http.StatusBadRequest, "invalid mode") - return - } - mode = uint32(m) - } - - if err := h.fs.Mkdir(path, mode); err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - writeJSON(w, http.StatusCreated, SuccessResponse{Message: "directory created"}) -} - -// ReadFile handles GET /files?path=<path>&offset=<offset>&size=<size>&stream=<true|false> -func (h *Handler) ReadFile(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - // Check if streaming mode is requested - stream := r.URL.Query().Get("stream") == "true" - if stream { - h.streamFile(w, r, path) - return - } - - // Parse offset and size parameters - offset := int64(0) - size := int64(-1) // -1 means read all - - if offsetStr := r.URL.Query().Get("offset"); offsetStr != "" { - if parsedOffset, err := strconv.ParseInt(offsetStr, 10, 64); err == nil { - offset = parsedOffset - } else { - writeError(w, http.StatusBadRequest, "invalid offset parameter") - return - } - } - - if sizeStr := r.URL.Query().Get("size"); sizeStr != "" { - if parsedSize, err := strconv.ParseInt(sizeStr, 10, 64); err == nil { - size = parsedSize - } else { - writeError(w, http.StatusBadRequest, "invalid size parameter") - return - } - } - - data, err := h.fs.Read(path, offset, size) - if err != nil { - // Check if it's EOF (reached end of file) - if err == io.EOF { - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - w.Write(data) // Return partial data with 200 OK - // Record downstream traffic - if h.trafficMonitor != nil && len(data) > 0 { - h.trafficMonitor.RecordRead(int64(len(data))) - } - return - } - // Map error to appropriate HTTP status code - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - w.Header().Set("Content-Type", "application/octet-stream") - w.WriteHeader(http.StatusOK) - w.Write(data) - - // Record downstream traffic - if h.trafficMonitor != nil && len(data) > 0 { - h.trafficMonitor.RecordRead(int64(len(data))) - } -} - -// WriteFile handles PUT /files?path=<path> -func (h *Handler) WriteFile(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - data, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - - // Record upstream traffic - if h.trafficMonitor != nil && len(data) > 0 { - h.trafficMonitor.RecordWrite(int64(len(data))) - } - - // Use default flags: create if not exists, truncate (like the old behavior) - bytesWritten, err := h.fs.Write(path, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - // Return success with bytes written - writeJSON(w, http.StatusOK, SuccessResponse{Message: fmt.Sprintf("Written %d bytes", bytesWritten)}) -} - -// Delete handles DELETE /files?path=<path>&recursive=<true|false> -func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - recursive := r.URL.Query().Get("recursive") == "true" - - var err error - if recursive { - err = h.fs.RemoveAll(path) - } else { - err = h.fs.Remove(path) - } - - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "deleted"}) -} - -// ListDirectory handles GET /directories?path=<path> -func (h *Handler) ListDirectory(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - path = "/" - } - - files, err := h.fs.ReadDir(path) - if err != nil { - // Map error to appropriate HTTP status code - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - var response ListResponse - for _, f := range files { - response.Files = append(response.Files, FileInfoResponse{ - Name: f.Name, - Size: f.Size, - Mode: f.Mode, - ModTime: f.ModTime.Format(time.RFC3339Nano), - IsDir: f.IsDir, - Meta: f.Meta, - }) - } - - writeJSON(w, http.StatusOK, response) -} - -// Stat handles GET /stat?path=<path> -func (h *Handler) Stat(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - info, err := h.fs.Stat(path) - if err != nil { - status := mapErrorToStatus(err) - // "Not found" is expected during cp/mv operations, use debug level - if status == http.StatusNotFound { - log.Debugf("Stat: path not found: %s (from %s)", path, r.RemoteAddr) - } else { - log.Errorf("Stat error for path %s: %v (from %s)", path, err, r.RemoteAddr) - } - writeError(w, status, err.Error()) - return - } - - response := FileInfoResponse{ - Name: info.Name, - Size: info.Size, - Mode: info.Mode, - ModTime: info.ModTime.Format(time.RFC3339Nano), - IsDir: info.IsDir, - Meta: info.Meta, - } - - writeJSON(w, http.StatusOK, response) -} - -// Rename handles POST /rename?path=<path> -func (h *Handler) Rename(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - var req RenameRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.NewPath == "" { - writeError(w, http.StatusBadRequest, "newPath is required") - return - } - - if err := h.fs.Rename(path, req.NewPath); err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "renamed"}) -} - -// Chmod handles POST /chmod?path=<path> -func (h *Handler) Chmod(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - var req ChmodRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if err := h.fs.Chmod(path, req.Mode); err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "permissions changed"}) -} - -// Digest handles POST /digest -func (h *Handler) Digest(w http.ResponseWriter, r *http.Request) { - var req DigestRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - // Validate algorithm - if req.Algorithm != "xxh3" && req.Algorithm != "md5" { - writeError(w, http.StatusBadRequest, fmt.Sprintf("unsupported algorithm: %s (supported: xxh3, md5)", req.Algorithm)) - return - } - - // Validate path - if req.Path == "" { - writeError(w, http.StatusBadRequest, "path is required") - return - } - - // Calculate digest using streaming approach to handle large files - var digest string - var err error - - switch req.Algorithm { - case "xxh3": - digest, err = h.calculateXXH3Digest(req.Path) - case "md5": - digest, err = h.calculateMD5Digest(req.Path) - default: - writeError(w, http.StatusBadRequest, "unsupported algorithm: "+req.Algorithm) - return - } - - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, "failed to calculate digest: "+err.Error()) - return - } - - response := DigestResponse{ - Algorithm: req.Algorithm, - Path: req.Path, - Digest: digest, - } - - writeJSON(w, http.StatusOK, response) -} - -// calculateXXH3Digest calculates XXH3 hash using streaming approach -func (h *Handler) calculateXXH3Digest(path string) (string, error) { - // Try to open file for streaming - reader, err := h.fs.Open(path) - if err != nil { - return "", err - } - defer reader.Close() - - // Stream and hash the file in chunks - hasher := xxh3.New() - buffer := make([]byte, 64*1024) // 64KB buffer - - for { - n, err := reader.Read(buffer) - if n > 0 { - hasher.Write(buffer[:n]) - } - if err == io.EOF { - break - } - if err != nil { - return "", fmt.Errorf("error reading file: %w", err) - } - } - - hash := hasher.Sum128().Lo // Use lower 64 bits for consistency - return fmt.Sprintf("%016x", hash), nil -} - -// calculateMD5Digest calculates MD5 hash using streaming approach -func (h *Handler) calculateMD5Digest(path string) (string, error) { - // Try to open file for streaming - reader, err := h.fs.Open(path) - if err != nil { - return "", err - } - defer reader.Close() - - // Stream and hash the file in chunks - hasher := md5.New() - buffer := make([]byte, 64*1024) // 64KB buffer - - for { - n, err := reader.Read(buffer) - if n > 0 { - hasher.Write(buffer[:n]) - } - if err == io.EOF { - break - } - if err != nil { - return "", fmt.Errorf("error reading file: %w", err) - } - } - - return hex.EncodeToString(hasher.Sum(nil)), nil -} - -// CapabilitiesResponse represents the server capabilities -type CapabilitiesResponse struct { - Version string `json:"version"` - Features []string `json:"features"` -} - -// Capabilities handles GET /capabilities -func (h *Handler) Capabilities(w http.ResponseWriter, r *http.Request) { - response := CapabilitiesResponse{ - Version: h.version, - Features: []string{ - "handlefs", // File handles for stateful operations - "grep", // Server-side grep - "digest", // Server-side checksums - "stream", // Streaming read - "touch", // Touch/update timestamp - }, - } - writeJSON(w, http.StatusOK, response) -} - -// HealthResponse represents the health check response -type HealthResponse struct { - Status string `json:"status"` - Version string `json:"version"` - GitCommit string `json:"gitCommit"` - BuildTime string `json:"buildTime"` -} - -// Health handles GET /health -func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { - response := HealthResponse{ - Status: "healthy", - Version: h.version, - GitCommit: h.gitCommit, - BuildTime: h.buildTime, - } - writeJSON(w, http.StatusOK, response) -} - -// Touch handles POST /touch?path=<path> -// Updates file timestamp without changing content -// If file doesn't exist, creates it with empty content -func (h *Handler) Touch(w http.ResponseWriter, r *http.Request) { - path := r.URL.Query().Get("path") - if path == "" { - writeError(w, http.StatusBadRequest, "path parameter is required") - return - } - - // Check if filesystem implements efficient Touch - if toucher, ok := h.fs.(filesystem.Toucher); ok { - // Use efficient touch implementation - err := toucher.Touch(path) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - writeJSON(w, http.StatusOK, SuccessResponse{Message: "touched"}) - return - } - - // Fallback: inefficient implementation for filesystems without Touch - // Check if file exists - info, err := h.fs.Stat(path) - if err == nil { - // File exists - read current content and write it back to update timestamp - if !info.IsDir { - data, readErr := h.fs.Read(path, 0, -1) - if readErr != nil { - status := mapErrorToStatus(readErr) - writeError(w, status, readErr.Error()) - return - } - _, writeErr := h.fs.Write(path, data, -1, filesystem.WriteFlagTruncate) - if writeErr != nil { - status := mapErrorToStatus(writeErr) - writeError(w, status, writeErr.Error()) - return - } - } else { - // Can't touch a directory - writeError(w, http.StatusBadRequest, "cannot touch directory") - return - } - } else { - // File doesn't exist - create with empty content - _, err := h.fs.Write(path, []byte{}, -1, filesystem.WriteFlagCreate) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, err.Error()) - return - } - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "touched"}) -} - -// SetupRoutes sets up all HTTP routes with /api/v1 prefix -func (h *Handler) SetupRoutes(mux *http.ServeMux) { - mux.HandleFunc("/api/v1/health", h.Health) - mux.HandleFunc("/api/v1/capabilities", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Capabilities(w, r) - }) - - // Setup handle routes (file handles for stateful operations) - h.SetupHandleRoutes(mux) - - mux.HandleFunc("/api/v1/files", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - h.CreateFile(w, r) - case http.MethodGet: - h.ReadFile(w, r) - case http.MethodPut: - h.WriteFile(w, r) - case http.MethodDelete: - h.Delete(w, r) - default: - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - } - }) - mux.HandleFunc("/api/v1/directories", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - h.CreateDirectory(w, r) - case http.MethodGet: - h.ListDirectory(w, r) - case http.MethodDelete: - h.Delete(w, r) - default: - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - } - }) - mux.HandleFunc("/api/v1/stat", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Stat(w, r) - }) - mux.HandleFunc("/api/v1/rename", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Rename(w, r) - }) - mux.HandleFunc("/api/v1/chmod", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Chmod(w, r) - }) - mux.HandleFunc("/api/v1/grep", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Grep(w, r) - }) - mux.HandleFunc("/api/v1/digest", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Digest(w, r) - }) - mux.HandleFunc("/api/v1/touch", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - h.Touch(w, r) - }) -} - -// streamFile handles streaming file reads with HTTP chunked transfer encoding -func (h *Handler) streamFile(w http.ResponseWriter, r *http.Request, path string) { - // Check if filesystem supports streaming - streamer, ok := h.fs.(filesystem.Streamer) - if !ok { - writeError(w, http.StatusBadRequest, "streaming not supported for this filesystem") - return - } - - // Open stream for reading - reader, err := streamer.OpenStream(path) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) - return - } - defer reader.Close() - - // Stream data to client - h.streamFromStreamReader(w, r, reader) -} - -// streamFromStreamReader streams data from a filesystem.StreamReader using chunked transfer -func (h *Handler) streamFromStreamReader(w http.ResponseWriter, r *http.Request, reader filesystem.StreamReader) { - // Set headers for chunked transfer - w.Header().Set("Content-Type", "application/octet-stream") - w.Header().Set("Transfer-Encoding", "chunked") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.WriteHeader(http.StatusOK) - - flusher, ok := w.(http.Flusher) - if !ok { - log.Error("ResponseWriter does not support flushing") - return - } - - log.Debugf("Starting stream read") - - // Read timeout for each chunk - timeout := 30 * time.Second - - for { - // Check if client disconnected - select { - case <-r.Context().Done(): - log.Infof("Client disconnected from stream") - return - default: - } - - // Read next chunk from stream (blocking until data available) - chunk, eof, err := reader.ReadChunk(timeout) - - if err != nil { - if err == io.EOF { - log.Infof("Stream closed (EOF)") - return - } - if err.Error() == "read timeout" { - // Timeout - stream is idle, continue waiting instead of closing - log.Debugf("Stream read timeout, continuing to wait...") - continue - } - log.Errorf("Error reading from stream: %v", err) - return - } - - if len(chunk) > 0 { - // Write chunk to response in smaller pieces to avoid overwhelming the client - maxChunkSize := 64 * 1024 // 64KB at a time - offset := 0 - - for offset < len(chunk) { - // Check if client disconnected - select { - case <-r.Context().Done(): - log.Infof("Client disconnected while writing chunk") - return - default: - } - end := offset + maxChunkSize - if end > len(chunk) { - end = len(chunk) - } - n, writeErr := w.Write(chunk[offset:end]) - if writeErr != nil { - log.Debugf("Error writing chunk: %v (this is normal if client disconnected)", writeErr) - return - } - // Record downstream traffic - if h.trafficMonitor != nil && n > 0 { - h.trafficMonitor.RecordRead(int64(n)) - } - offset += n - // Flush after each piece - flusher.Flush() - } - } - if eof { - log.Debug("Stream completed (EOF)") - return - } - } -} - -// GrepRequest represents a grep search request -type GrepRequest struct { - Path string `json:"path"` // Path to file or directory to search - Pattern string `json:"pattern"` // Regular expression pattern - Recursive bool `json:"recursive"` // Whether to search recursively in directories - CaseInsensitive bool `json:"case_insensitive"` // Case-insensitive matching - Stream bool `json:"stream"` // Stream results as NDJSON (one match per line) - NodeLimit int `json:"node_limit"` // Maximum number of results to return (0 means no limit) -} - -// GrepMatch represents a single match result -type GrepMatch struct { - File string `json:"file"` // File path - Line int `json:"line"` // Line number (1-indexed) - Content string `json:"content"` // Matched line content -} - -// GrepResponse represents the grep search results -type GrepResponse struct { - Matches []GrepMatch `json:"matches"` // All matches - Count int `json:"count"` // Total number of matches -} - -type localPathResolver interface { - ResolvePath(path string) string -} - -var rgVimgrepSepRe = regexp.MustCompile(`:(\d+):(\d+):`) - -// Grep searches for a pattern in files -func (h *Handler) Grep(w http.ResponseWriter, r *http.Request) { - var req GrepRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) - return - } - - // Validate request - if req.Path == "" { - writeError(w, http.StatusBadRequest, "path is required") - return - } - if req.Pattern == "" { - writeError(w, http.StatusBadRequest, "pattern is required") - return - } - - // Compile regex pattern - var re *regexp.Regexp - var err error - if req.CaseInsensitive { - re, err = regexp.Compile("(?i)" + req.Pattern) - } else { - re, err = regexp.Compile(req.Pattern) - } - if err != nil { - writeError(w, http.StatusBadRequest, "invalid regex pattern: "+err.Error()) - return - } - - localPath, basePath, mountPath, useRipgrep := h.resolveRipgrepPath(req.Path) - - // Check if path exists and get file info - info, err := h.fs.Stat(req.Path) - if err != nil { - status := mapErrorToStatus(err) - writeError(w, status, "failed to stat path: "+err.Error()) - return - } - - // Handle stream mode - if req.Stream { - if useRipgrep { - h.grepStreamRipgrep(w, localPath, basePath, mountPath, req.Pattern, info.IsDir, req.Recursive, req.CaseInsensitive, req.NodeLimit) - } else { - h.grepStream(w, req.Path, re, info.IsDir, req.Recursive, req.NodeLimit) - } - return - } - - // Non-stream mode: collect all matches - var matches []GrepMatch - - // Search in file or directory - if info.IsDir { - if req.Recursive { - if useRipgrep { - matches, err = h.grepWithRipgrep(localPath, basePath, mountPath, req.Pattern, req.CaseInsensitive, req.NodeLimit) - } else { - matches, err = h.grepDirectory(req.Path, re, req.NodeLimit) - } - } else { - writeError(w, http.StatusBadRequest, "path is a directory, use recursive=true to search") - return - } - } else { - if useRipgrep { - matches, err = h.grepWithRipgrep(localPath, basePath, mountPath, req.Pattern, req.CaseInsensitive, req.NodeLimit) - } else { - matches, err = h.grepFile(req.Path, re, req.NodeLimit) - } - } - - response := GrepResponse{ - Matches: matches, - Count: len(matches), - } - - writeJSON(w, http.StatusOK, response) -} - -func (h *Handler) resolveRipgrepPath(vfsPath string) (string, string, string, bool) { - if _, err := exec.LookPath("rg"); err != nil { - return "", "", "", false - } - - if mfs, ok := h.fs.(*mountablefs.MountableFS); ok { - mount, relPath, found := findMountForPath(mfs.GetMounts(), vfsPath) - if !found { - return "", "", "", false - } - resolver, ok := mount.Plugin.GetFileSystem().(localPathResolver) - if !ok { - return "", "", "", false - } - localPath := resolver.ResolvePath(relPath) - basePath := resolver.ResolvePath("/") - return localPath, basePath, mount.Path, true - } - - resolver, ok := h.fs.(localPathResolver) - if !ok { - return "", "", "", false - } - localPath := resolver.ResolvePath(vfsPath) - basePath := resolver.ResolvePath("/") - return localPath, basePath, "/", true -} - -func (h *Handler) grepStreamRipgrep(w http.ResponseWriter, localPath string, basePath string, mountPath string, pattern string, isDir bool, recursive bool, caseInsensitive bool, nodeLimit int) { - w.Header().Set("Content-Type", "application/x-ndjson") - w.Header().Set("Transfer-Encoding", "chunked") - w.WriteHeader(http.StatusOK) - - flusher, ok := w.(http.Flusher) - if !ok { - log.Error("Streaming not supported") - return - } - - matchCount := 0 - encoder := json.NewEncoder(w) - - sendMatch := func(match GrepMatch) error { - matchCount++ - if err := encoder.Encode(match); err != nil { - return err - } - flusher.Flush() - return nil - } - - var err error - if isDir { - if !recursive { - errMatch := map[string]interface{}{ - "error": "path is a directory, use recursive=true to search", - } - encoder.Encode(errMatch) - flusher.Flush() - return - } - _, err = h.grepWithRipgrepStream(localPath, basePath, mountPath, pattern, caseInsensitive, nodeLimit, sendMatch) - } else { - _, err = h.grepWithRipgrepStream(localPath, basePath, mountPath, pattern, caseInsensitive, nodeLimit, sendMatch) - } - - summary := map[string]interface{}{ - "type": "summary", - "count": matchCount, - } - if err != nil { - summary["error"] = err.Error() - } - encoder.Encode(summary) - flusher.Flush() -} - -func (h *Handler) grepWithRipgrep(localPath string, basePath string, mountPath string, pattern string, caseInsensitive bool, nodeLimit int) ([]GrepMatch, error) { - matches := make([]GrepMatch, 0) - _, err := h.grepWithRipgrepStream(localPath, basePath, mountPath, pattern, caseInsensitive, nodeLimit, func(match GrepMatch) error { - matches = append(matches, match) - return nil - }) - if err != nil { - return nil, err - } - return matches, nil -} - -func (h *Handler) grepWithRipgrepStream(localPath string, basePath string, mountPath string, pattern string, caseInsensitive bool, nodeLimit int, callback func(GrepMatch) error) (int, error) { - args := []string{"--vimgrep", "--no-heading", "--color=never"} - if caseInsensitive { - args = append(args, "-i") - } - if nodeLimit > 0 { - args = append(args, "--max-count", strconv.Itoa(nodeLimit)) - } - args = append(args, "--", pattern, localPath) - - cmd := exec.Command("rg", args...) - stdout, err := cmd.StdoutPipe() - if err != nil { - return 0, err - } - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { - return 0, err - } - - count := 0 - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for scanner.Scan() { - filePath, lineNum, content, ok := parseRipgrepLine(scanner.Text()) - if !ok { - continue - } - vfsPath := vfsPathFromLocal(basePath, mountPath, filePath) - match := GrepMatch{ - File: vfsPath, - Line: lineNum, - Content: content, - } - if err := callback(match); err != nil { - _ = cmd.Process.Kill() - _ = cmd.Wait() - return count, err - } - count++ - if nodeLimit > 0 && count >= nodeLimit { - _ = cmd.Process.Kill() - _ = cmd.Wait() - return count, nil - } - } - if err := scanner.Err(); err != nil { - _ = cmd.Process.Kill() - _ = cmd.Wait() - return count, err - } - if err := cmd.Wait(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - if exitErr.ExitCode() == 1 { - return count, nil - } - if stderr.Len() > 0 { - return count, errors.New(strings.TrimSpace(stderr.String())) - } - } - return count, err - } - return count, nil -} - -func parseRipgrepLine(line string) (string, int, string, bool) { - matches := rgVimgrepSepRe.FindAllStringSubmatchIndex(line, -1) - if len(matches) == 0 { - return "", 0, "", false - } - m := matches[0] - if len(m) < 6 { - return "", 0, "", false - } - filePath := line[:m[0]] - lineStr := line[m[2]:m[3]] - content := line[m[1]:] - lineNum, err := strconv.Atoi(lineStr) - if err != nil { - return "", 0, "", false - } - return filePath, lineNum, content, true -} - -func vfsPathFromLocal(basePath string, mountPath string, localPath string) string { - rel, err := filepath.Rel(basePath, localPath) - if err != nil { - return localPath - } - rel = filepath.ToSlash(rel) - if rel == "." { - return mountPath - } - if strings.HasPrefix(rel, "..") { - return localPath - } - mountPath = path.Clean("/" + strings.TrimPrefix(mountPath, "/")) - if mountPath == "/" { - return "/" + rel - } - return mountPath + "/" + rel -} - -func findMountForPath(mounts []*mountablefs.MountPoint, targetPath string) (*mountablefs.MountPoint, string, bool) { - targetPath = filesystem.NormalizePath(targetPath) - var best *mountablefs.MountPoint - bestLen := -1 - bestRel := "" - - for _, m := range mounts { - mountPath := filesystem.NormalizePath(m.Path) - rel, ok := matchMountPath(mountPath, targetPath) - if !ok { - continue - } - if len(mountPath) > bestLen { - best = m - bestLen = len(mountPath) - bestRel = rel - } - } - - if best == nil { - return nil, "", false - } - return best, bestRel, true -} - -func matchMountPath(mountPath string, targetPath string) (string, bool) { - if mountPath == "/" { - return targetPath, true - } - if targetPath == mountPath { - return "/", true - } - if strings.HasPrefix(targetPath, mountPath) && len(targetPath) > len(mountPath) && targetPath[len(mountPath)] == '/' { - return targetPath[len(mountPath):], true - } - return "", false -} - -// grepStream handles streaming grep results as NDJSON -func (h *Handler) grepStream(w http.ResponseWriter, path string, re *regexp.Regexp, isDir bool, recursive bool, nodeLimit int) { - // Set headers for NDJSON streaming - w.Header().Set("Content-Type", "application/x-ndjson") - w.Header().Set("Transfer-Encoding", "chunked") - w.WriteHeader(http.StatusOK) - - // Get flusher for chunked encoding - flusher, ok := w.(http.Flusher) - if !ok { - log.Error("Streaming not supported") - return - } - - matchCount := 0 - encoder := json.NewEncoder(w) - - // Callback function to send each match - sendMatch := func(match GrepMatch) error { - matchCount++ - if err := encoder.Encode(match); err != nil { - return err - } - flusher.Flush() - return nil - } - - // Search and stream results - var err error - if isDir { - if !recursive { - // Send error as JSON - errMatch := map[string]interface{}{ - "error": "path is a directory, use recursive=true to search", - } - encoder.Encode(errMatch) - flusher.Flush() - return - } - _, err = h.grepDirectoryStream(path, re, nodeLimit, sendMatch) - } else { - _, err = h.grepFileStream(path, re, nodeLimit, sendMatch) - } - - // Send final summary with count - summary := map[string]interface{}{ - "type": "summary", - "count": matchCount, - } - if err != nil { - summary["error"] = err.Error() - } - encoder.Encode(summary) - flusher.Flush() -} - -// grepFileStream searches for pattern in a single file and calls callback for each match -func (h *Handler) grepFileStream(path string, re *regexp.Regexp, nodeLimit int, callback func(GrepMatch) error) (int, error) { - // Read file content - data, err := h.fs.Read(path, 0, -1) - // io.EOF is normal when reading entire file, only return error for other errors - if err != nil && err != io.EOF { - return 0, err - } - - scanner := bufio.NewScanner(bytes.NewReader(data)) - lineNum := 1 - count := 0 - - for scanner.Scan() { - if nodeLimit > 0 && count >= nodeLimit { - break - } - line := scanner.Text() - if re.MatchString(line) { - match := GrepMatch{ - File: path, - Line: lineNum, - Content: line, - } - if err := callback(match); err != nil { - return count, err - } - count++ - } - lineNum++ - } - - if err := scanner.Err(); err != nil { - return count, err - } - - return count, nil -} - -// grepDirectoryStream recursively searches for pattern in a directory and calls callback for each match -func (h *Handler) grepDirectoryStream(dirPath string, re *regexp.Regexp, nodeLimit int, callback func(GrepMatch) error) (int, error) { - // List directory contents - entries, err := h.fs.ReadDir(dirPath) - if err != nil { - return 0, err - } - - totalCount := 0 - - for _, entry := range entries { - if nodeLimit > 0 && totalCount >= nodeLimit { - break - } - // Build full path - // Use path.Join for VFS paths to ensure forward slashes on all OS - fullPath := path.Join(dirPath, entry.Name) - - if entry.IsDir { - // Recursively search subdirectories - count, err := h.grepDirectoryStream(fullPath, re, nodeLimit-totalCount, callback) - totalCount += count - if err != nil { - // Log error but continue searching other files - log.Warnf("failed to search directory %s: %v", fullPath, err) - continue - } - } else { - // Search in file - count, err := h.grepFileStream(fullPath, re, nodeLimit-totalCount, callback) - totalCount += count - if err != nil { - // Log error but continue searching other files - log.Warnf("failed to search file %s: %v", fullPath, err) - continue - } - } - } - - return totalCount, nil -} - -// grepFile searches for pattern in a single file -func (h *Handler) grepFile(path string, re *regexp.Regexp, nodeLimit int) ([]GrepMatch, error) { - // Read file content - data, err := h.fs.Read(path, 0, -1) - // io.EOF is normal when reading entire file, only return error for other errors - if err != nil && err != io.EOF { - return nil, err - } - - var matches []GrepMatch - scanner := bufio.NewScanner(bytes.NewReader(data)) - lineNum := 1 - - for scanner.Scan() { - if nodeLimit > 0 && len(matches) >= nodeLimit { - break - } - line := scanner.Text() - if re.MatchString(line) { - matches = append(matches, GrepMatch{ - File: path, - Line: lineNum, - Content: line, - }) - } - lineNum++ - } - - if err := scanner.Err(); err != nil { - return nil, err - } - - return matches, nil -} - -// grepDirectory recursively searches for pattern in a directory -func (h *Handler) grepDirectory(dirPath string, re *regexp.Regexp, nodeLimit int) ([]GrepMatch, error) { - var allMatches []GrepMatch - - // List directory contents - entries, err := h.fs.ReadDir(dirPath) - if err != nil { - return nil, err - } - - for _, entry := range entries { - if nodeLimit > 0 && len(allMatches) >= nodeLimit { - break - } - // Build full path - // Use path.Join for VFS paths to ensure forward slashes on all OS - fullPath := path.Join(dirPath, entry.Name) - - if entry.IsDir { - // Recursively search subdirectories - subMatches, err := h.grepDirectory(fullPath, re, nodeLimit-len(allMatches)) - if err != nil { - // Log error but continue searching other files - log.Warnf("failed to search directory %s: %v", fullPath, err) - continue - } - allMatches = append(allMatches, subMatches...) - } else { - // Search in file - matches, err := h.grepFile(fullPath, re, nodeLimit-len(allMatches)) - if err != nil { - // Log error but continue searching other files - log.Warnf("failed to search file %s: %v", fullPath, err) - continue - } - allMatches = append(allMatches, matches...) - } - } - - return allMatches, nil -} - -// LoggingMiddleware logs HTTP requests -func LoggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - if r.URL.RawQuery != "" { - path += "?" + r.URL.RawQuery - } - log.Debugf("%s %s", r.Method, path) - next.ServeHTTP(w, r) - }) -} diff --git a/third_party/agfs/agfs-server/pkg/handlers/handlers_test.go b/third_party/agfs/agfs-server/pkg/handlers/handlers_test.go deleted file mode 100644 index 30eb88e0f..000000000 --- a/third_party/agfs/agfs-server/pkg/handlers/handlers_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package handlers - -import ( - "testing" -) - -func TestParseRipgrepLine(t *testing.T) { - tests := []struct { - name string - line string - wantFile string - wantLine int - wantContent string - wantOk bool - }{ - { - name: "normal line", - line: "/path/to/file.go:10:5:some content", - wantFile: "/path/to/file.go", - wantLine: 10, - wantContent: "some content", - wantOk: true, - }, - { - name: "content contains :digit:digit: pattern", - line: "/path/to/file.go:10:5:error at position 20:3: invalid token", - wantFile: "/path/to/file.go", - wantLine: 10, - wantContent: "error at position 20:3: invalid token", - wantOk: true, - }, - { - name: "content contains multiple :digit:digit: patterns", - line: "/path/to/file.go:42:1:fmt.Sprintf(\"%d:%d:\", 1, 2)", - wantFile: "/path/to/file.go", - wantLine: 42, - wantContent: "fmt.Sprintf(\"%d:%d:\", 1, 2)", - wantOk: true, - }, - { - name: "no separator", - line: "just some text", - wantOk: false, - }, - { - name: "empty line", - line: "", - wantOk: false, - }, - { - name: "col is zero", - line: "/src/main.go:1:0:package main", - wantFile: "/src/main.go", - wantLine: 1, - wantContent: "package main", - wantOk: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - file, line, content, ok := parseRipgrepLine(tt.line) - if ok != tt.wantOk { - t.Fatalf("ok = %v, want %v", ok, tt.wantOk) - } - if !ok { - return - } - if file != tt.wantFile { - t.Errorf("file = %q, want %q", file, tt.wantFile) - } - if line != tt.wantLine { - t.Errorf("line = %d, want %d", line, tt.wantLine) - } - if content != tt.wantContent { - t.Errorf("content = %q, want %q", content, tt.wantContent) - } - }) - } -} diff --git a/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go b/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go deleted file mode 100644 index 8b1139051..000000000 --- a/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go +++ /dev/null @@ -1,477 +0,0 @@ -package handlers - -import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - log "github.com/sirupsen/logrus" -) - -// PluginHandler handles plugin management operations -type PluginHandler struct { - mfs *mountablefs.MountableFS -} - -// NewPluginHandler creates a new plugin handler -func NewPluginHandler(mfs *mountablefs.MountableFS) *PluginHandler { - return &PluginHandler{mfs: mfs} -} - -// MountInfo represents information about a mounted plugin -type MountInfo struct { - Path string `json:"path"` - PluginName string `json:"pluginName"` - Config map[string]interface{} `json:"config,omitempty"` -} - -// ListMountsResponse represents the response for listing mounts -type ListMountsResponse struct { - Mounts []MountInfo `json:"mounts"` -} - -// ListMounts handles GET /mounts -func (ph *PluginHandler) ListMounts(w http.ResponseWriter, r *http.Request) { - mounts := ph.mfs.GetMounts() - - var mountInfos []MountInfo - for _, mount := range mounts { - mountInfos = append(mountInfos, MountInfo{ - Path: mount.Path, - PluginName: mount.Plugin.Name(), - Config: mount.Config, - }) - } - - writeJSON(w, http.StatusOK, ListMountsResponse{Mounts: mountInfos}) -} - -// UnmountRequest represents an unmount request -type UnmountRequest struct { - Path string `json:"path"` -} - -// Unmount handles POST /unmount -func (ph *PluginHandler) Unmount(w http.ResponseWriter, r *http.Request) { - var req UnmountRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Path == "" { - writeError(w, http.StatusBadRequest, "path is required") - return - } - - if err := ph.mfs.Unmount(req.Path); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "plugin unmounted"}) -} - -// MountRequest represents a mount request -type MountRequest struct { - FSType string `json:"fstype"` - Path string `json:"path"` - Config map[string]interface{} `json:"config"` -} - -// Mount handles POST /mount -func (ph *PluginHandler) Mount(w http.ResponseWriter, r *http.Request) { - var req MountRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.FSType == "" { - writeError(w, http.StatusBadRequest, "fstype is required") - return - } - - if req.Path == "" { - writeError(w, http.StatusBadRequest, "path is required") - return - } - - if err := ph.mfs.MountPlugin(req.FSType, req.Path, req.Config); err != nil { - // First check for typed errors - if errors.Is(err, filesystem.ErrAlreadyExists) { - writeError(w, http.StatusConflict, err.Error()) - return - } - - // For backward compatibility, check string-based errors that aren't typed yet - errMsg := err.Error() - if strings.Contains(errMsg, "unknown filesystem type") || strings.Contains(errMsg, "unknown plugin") || - strings.Contains(errMsg, "failed to validate") || strings.Contains(errMsg, "is required") || - strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "unknown configuration parameter") { - writeError(w, http.StatusBadRequest, err.Error()) - } else { - writeError(w, http.StatusInternalServerError, err.Error()) - } - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "plugin mounted"}) -} - - -// LoadPluginRequest represents a request to load an external plugin -type LoadPluginRequest struct { - LibraryPath string `json:"library_path"` -} - -// LoadPluginResponse represents the response for loading a plugin -type LoadPluginResponse struct { - Message string `json:"message"` - PluginName string `json:"plugin_name"` - OriginalName string `json:"original_name,omitempty"` - Renamed bool `json:"renamed"` -} - -// isHTTPURL checks if a string is an HTTP or HTTPS URL -func isHTTPURL(path string) bool { - return strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") -} - -// isAGFSPath checks if a string is a AGFS path (agfs://) -func isAGFSPath(path string) bool { - return strings.HasPrefix(path, "agfs://") -} - -// downloadPluginFromURL downloads a plugin from an HTTP(S) URL to a temporary file -func downloadPluginFromURL(url string) (string, error) { - log.Infof("Downloading plugin from URL: %s", url) - - // Create HTTP request - resp, err := http.Get(url) - if err != nil { - return "", fmt.Errorf("failed to download from URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("failed to download from URL: HTTP %d", resp.StatusCode) - } - - // Determine file extension from URL - ext := filepath.Ext(url) - if ext == "" { - // Default to .so if no extension - ext = ".so" - } - - // Create a hash of the URL to use as the filename - hash := sha256.Sum256([]byte(url)) - hashStr := hex.EncodeToString(hash[:])[:16] - - // Create temporary file with appropriate extension - tmpDir := os.TempDir() - tmpFile := filepath.Join(tmpDir, fmt.Sprintf("agfs-plugin-%s%s", hashStr, ext)) - - // Create the file - outFile, err := os.Create(tmpFile) - if err != nil { - return "", fmt.Errorf("failed to create temporary file: %w", err) - } - defer outFile.Close() - - // Copy the downloaded content to the file - written, err := io.Copy(outFile, resp.Body) - if err != nil { - os.Remove(tmpFile) - return "", fmt.Errorf("failed to write downloaded content: %w", err) - } - - log.Infof("Downloaded plugin to temporary file: %s (%d bytes)", tmpFile, written) - return tmpFile, nil -} - -// readPluginFromAGFS reads a plugin from a AGFS path (agfs://...) to a temporary file -func (ph *PluginHandler) readPluginFromAGFS(agfsPath string) (string, error) { - // Remove agfs:// prefix to get the actual path - path := strings.TrimPrefix(agfsPath, "agfs://") - if path == "" || path == "/" { - return "", fmt.Errorf("invalid agfs path: %s", agfsPath) - } - - // Ensure path starts with / - if !strings.HasPrefix(path, "/") { - path = "/" + path - } - - log.Infof("Reading plugin from AGFS path: %s", path) - - // Read file from the mountable filesystem - data, err := ph.mfs.Read(path, 0, -1) - if err != nil && err != io.EOF { - return "", fmt.Errorf("failed to read from AGFS path %s: %w", path, err) - } - - // Determine file extension from path - ext := filepath.Ext(path) - if ext == "" { - // Default to .so if no extension - ext = ".so" - } - - // Create a hash of the path to use as the filename - hash := sha256.Sum256([]byte(agfsPath)) - hashStr := hex.EncodeToString(hash[:])[:16] - - // Create temporary file with appropriate extension - tmpDir := os.TempDir() - tmpFile := filepath.Join(tmpDir, fmt.Sprintf("agfs-plugin-%s%s", hashStr, ext)) - - // Write the data to the temporary file - if err := os.WriteFile(tmpFile, data, 0644); err != nil { - return "", fmt.Errorf("failed to write temporary file: %w", err) - } - - log.Infof("Read plugin from AGFS to temporary file: %s (%d bytes)", tmpFile, len(data)) - return tmpFile, nil -} - -// LoadPlugin handles POST /plugins/load -func (ph *PluginHandler) LoadPlugin(w http.ResponseWriter, r *http.Request) { - var req LoadPluginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.LibraryPath == "" { - writeError(w, http.StatusBadRequest, "library_path is required") - return - } - - // Check if the library path is an HTTP(S) URL or AGFS path - libraryPath := req.LibraryPath - var tmpFile string - if isHTTPURL(libraryPath) { - // Download the plugin from the URL - downloadedFile, err := downloadPluginFromURL(libraryPath) - if err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to download plugin: %v", err)) - return - } - tmpFile = downloadedFile - libraryPath = downloadedFile - log.Infof("Using downloaded plugin from temporary file: %s", libraryPath) - } else if isAGFSPath(libraryPath) { - // Read the plugin from AGFS - agfsFile, err := ph.readPluginFromAGFS(libraryPath) - if err != nil { - writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to read plugin from AGFS: %v", err)) - return - } - tmpFile = agfsFile - libraryPath = agfsFile - log.Infof("Using plugin from AGFS temporary file: %s", libraryPath) - } - - plugin, err := ph.mfs.LoadExternalPlugin(libraryPath) - if err != nil { - // Clean up temporary file if it was downloaded - if tmpFile != "" { - os.Remove(tmpFile) - } - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - // Check if plugin was renamed - response := LoadPluginResponse{ - Message: "plugin loaded successfully", - PluginName: plugin.Name(), - Renamed: false, - } - - if renamedPlugin, ok := plugin.(*mountablefs.RenamedPlugin); ok { - response.OriginalName = renamedPlugin.OriginalName() - response.Renamed = true - } - - writeJSON(w, http.StatusOK, response) -} - -// UnloadPluginRequest represents a request to unload an external plugin -type UnloadPluginRequest struct { - LibraryPath string `json:"library_path"` -} - -// UnloadPlugin handles POST /plugins/unload -func (ph *PluginHandler) UnloadPlugin(w http.ResponseWriter, r *http.Request) { - var req UnloadPluginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - writeError(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.LibraryPath == "" { - writeError(w, http.StatusBadRequest, "library_path is required") - return - } - - if err := ph.mfs.UnloadExternalPlugin(req.LibraryPath); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - - writeJSON(w, http.StatusOK, SuccessResponse{Message: "plugin unloaded successfully"}) -} - -// PluginMountInfo represents mount information for a plugin -type PluginMountInfo struct { - Path string `json:"path"` - Config map[string]interface{} `json:"config,omitempty"` -} - -// PluginInfo represents detailed information about a loaded plugin -type PluginInfo struct { - Name string `json:"name"` - LibraryPath string `json:"library_path,omitempty"` - IsExternal bool `json:"is_external"` - MountedPaths []PluginMountInfo `json:"mounted_paths"` - ConfigParams []plugin.ConfigParameter `json:"config_params,omitempty"` -} - -// ListPluginsResponse represents the response for listing plugins -type ListPluginsResponse struct { - Plugins []PluginInfo `json:"plugins"` -} - -// ListPlugins handles GET /plugins -func (ph *PluginHandler) ListPlugins(w http.ResponseWriter, r *http.Request) { - // Get all mounts - mounts := ph.mfs.GetMounts() - - // Build a map of plugin name -> mount info and plugin instance - pluginMountsMap := make(map[string][]PluginMountInfo) - pluginInstanceMap := make(map[string]plugin.ServicePlugin) - pluginNamesSet := make(map[string]bool) - - for _, mount := range mounts { - pluginName := mount.Plugin.Name() - pluginNamesSet[pluginName] = true - pluginMountsMap[pluginName] = append(pluginMountsMap[pluginName], PluginMountInfo{ - Path: mount.Path, - Config: mount.Config, - }) - // Store plugin instance for getting config params - if _, exists := pluginInstanceMap[pluginName]; !exists { - pluginInstanceMap[pluginName] = mount.Plugin - } - } - - // Get plugin name to library path mapping (external plugins) - pluginNameToPath := ph.mfs.GetPluginNameToPathMap() - - // Add all external plugins to the set (even if not mounted) - for pluginName := range pluginNameToPath { - pluginNamesSet[pluginName] = true - } - - // Add all builtin plugins to the set - builtinPlugins := ph.mfs.GetBuiltinPluginNames() - for _, pluginName := range builtinPlugins { - pluginNamesSet[pluginName] = true - } - - // Build plugin info list - var plugins []PluginInfo - for pluginName := range pluginNamesSet { - info := PluginInfo{ - Name: pluginName, - MountedPaths: pluginMountsMap[pluginName], - IsExternal: false, - } - - // Check if this is an external plugin - if libPath, exists := pluginNameToPath[pluginName]; exists { - info.IsExternal = true - info.LibraryPath = libPath - } - - // Get config params from plugin instance if available - if pluginInstance, exists := pluginInstanceMap[pluginName]; exists { - info.ConfigParams = pluginInstance.GetConfigParams() - } else { - // For unmounted plugins, create a temporary instance to get config params - tempPlugin := ph.mfs.CreatePlugin(pluginName) - if tempPlugin != nil { - info.ConfigParams = tempPlugin.GetConfigParams() - } - } - - plugins = append(plugins, info) - } - - writeJSON(w, http.StatusOK, ListPluginsResponse{Plugins: plugins}) -} - -// SetupRoutes sets up plugin management routes with /api/v1 prefix -func (ph *PluginHandler) SetupRoutes(mux *http.ServeMux) { - mux.HandleFunc("/api/v1/mounts", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - ph.ListMounts(w, r) - }) - - mux.HandleFunc("/api/v1/mount", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - ph.Mount(w, r) - }) - - mux.HandleFunc("/api/v1/unmount", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - ph.Unmount(w, r) - }) - - // External plugin management endpoints - mux.HandleFunc("/api/v1/plugins", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - ph.ListPlugins(w, r) - }) - - mux.HandleFunc("/api/v1/plugins/load", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - ph.LoadPlugin(w, r) - }) - - mux.HandleFunc("/api/v1/plugins/unload", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - ph.UnloadPlugin(w, r) - }) -} diff --git a/third_party/agfs/agfs-server/pkg/handlers/traffic_monitor.go b/third_party/agfs/agfs-server/pkg/handlers/traffic_monitor.go deleted file mode 100644 index 04da4ca3f..000000000 --- a/third_party/agfs/agfs-server/pkg/handlers/traffic_monitor.go +++ /dev/null @@ -1,142 +0,0 @@ -package handlers - -import ( - "sync" - "sync/atomic" - "time" -) - -// TrafficMonitor monitors network traffic for all handlers -type TrafficMonitor struct { - // Byte counters (atomic) - bytesRead atomic.Int64 - bytesWritten atomic.Int64 - - // Time-based statistics - mu sync.RWMutex - lastCheckTime time.Time - lastBytesRead int64 - lastBytesWritten int64 - - // Current rates (bytes per second) - currentReadRate float64 - currentWriteRate float64 - - // Peak rates - peakReadRate float64 - peakWriteRate float64 - - // Total statistics - totalBytesRead int64 - totalBytesWritten int64 - startTime time.Time -} - -// NewTrafficMonitor creates a new traffic monitor -func NewTrafficMonitor() *TrafficMonitor { - now := time.Now() - tm := &TrafficMonitor{ - lastCheckTime: now, - startTime: now, - } - - // Start background rate calculator - go tm.updateRates() - - return tm -} - -// RecordRead records bytes read (download/downstream) -func (tm *TrafficMonitor) RecordRead(bytes int64) { - tm.bytesRead.Add(bytes) -} - -// RecordWrite records bytes written (upload/upstream) -func (tm *TrafficMonitor) RecordWrite(bytes int64) { - tm.bytesWritten.Add(bytes) -} - -// updateRates periodically calculates current transfer rates -func (tm *TrafficMonitor) updateRates() { - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for range ticker.C { - tm.calculateRates() - } -} - -// calculateRates calculates current transfer rates -func (tm *TrafficMonitor) calculateRates() { - tm.mu.Lock() - defer tm.mu.Unlock() - - now := time.Now() - elapsed := now.Sub(tm.lastCheckTime).Seconds() - - if elapsed <= 0 { - return - } - - // Get current counters - currentRead := tm.bytesRead.Load() - currentWrite := tm.bytesWritten.Load() - - // Calculate rates (bytes per second) - readDelta := currentRead - tm.lastBytesRead - writeDelta := currentWrite - tm.lastBytesWritten - - tm.currentReadRate = float64(readDelta) / elapsed - tm.currentWriteRate = float64(writeDelta) / elapsed - - // Update peak rates - if tm.currentReadRate > tm.peakReadRate { - tm.peakReadRate = tm.currentReadRate - } - if tm.currentWriteRate > tm.peakWriteRate { - tm.peakWriteRate = tm.currentWriteRate - } - - // Update totals - tm.totalBytesRead += readDelta - tm.totalBytesWritten += writeDelta - - // Update last check values - tm.lastCheckTime = now - tm.lastBytesRead = currentRead - tm.lastBytesWritten = currentWrite -} - -// TrafficStats contains traffic statistics -type TrafficStats struct { - // Current rates in bytes/s - DownstreamBps int64 `json:"downstream_bps"` // Download rate (bytes/second) - UpstreamBps int64 `json:"upstream_bps"` // Upload rate (bytes/second) - - // Peak rates in bytes/s - PeakDownstreamBps int64 `json:"peak_downstream_bps"` // Peak download rate (bytes/second) - PeakUpstreamBps int64 `json:"peak_upstream_bps"` // Peak upload rate (bytes/second) - - // Total transferred in bytes - TotalDownloadBytes int64 `json:"total_download_bytes"` - TotalUploadBytes int64 `json:"total_upload_bytes"` - - // Uptime - UptimeSeconds int64 `json:"uptime_seconds"` -} - -// GetStats returns current traffic statistics -func (tm *TrafficMonitor) GetStats() interface{} { - tm.mu.RLock() - defer tm.mu.RUnlock() - - return TrafficStats{ - DownstreamBps: int64(tm.currentReadRate), - UpstreamBps: int64(tm.currentWriteRate), - PeakDownstreamBps: int64(tm.peakReadRate), - PeakUpstreamBps: int64(tm.peakWriteRate), - TotalDownloadBytes: tm.totalBytesRead, - TotalUploadBytes: tm.totalBytesWritten, - UptimeSeconds: int64(time.Since(tm.startTime).Seconds()), - } -} diff --git a/third_party/agfs/agfs-server/pkg/mountablefs/concurrent_test.go b/third_party/agfs/agfs-server/pkg/mountablefs/concurrent_test.go deleted file mode 100644 index c03dd5f5c..000000000 --- a/third_party/agfs/agfs-server/pkg/mountablefs/concurrent_test.go +++ /dev/null @@ -1,254 +0,0 @@ -package mountablefs - -import ( - "sync" - "testing" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/memfs" -) - -// TestConcurrentHandleIDUniqueness tests that handle IDs are unique -// even under heavy concurrent load -func TestConcurrentHandleIDUniqueness(t *testing.T) { - mfs := NewMountableFS(api.PoolConfig{}) - - // Create and mount a single memfs instance - plugin := memfs.NewMemFSPlugin() - err := plugin.Initialize(map[string]interface{}{}) - if err != nil { - t.Fatalf("Failed to initialize plugin: %v", err) - } - - err = mfs.Mount("/fs", plugin) - if err != nil { - t.Fatalf("Failed to mount fs: %v", err) - } - - // Get the underlying MemoryFS - fs := plugin.GetFileSystem().(*memfs.MemoryFS) - - // Create multiple files for concurrent access - numFiles := 10 - for i := 0; i < numFiles; i++ { - err = fs.Create("/file" + string(rune('0'+i)) + ".txt") - if err != nil { - t.Fatalf("Failed to create file %d: %v", i, err) - } - } - - // Concurrently open many handles - numGoroutines := 100 - handlesPerGoroutine := 10 - - // Collect all generated handle IDs - var mu sync.Mutex - allIDs := make([]int64, 0, numGoroutines*handlesPerGoroutine) - - var wg sync.WaitGroup - wg.Add(numGoroutines) - - for g := 0; g < numGoroutines; g++ { - go func(goroutineID int) { - defer wg.Done() - - localIDs := make([]int64, 0, handlesPerGoroutine) - - // Each goroutine opens multiple handles - for i := 0; i < handlesPerGoroutine; i++ { - fileIdx := (goroutineID*handlesPerGoroutine + i) % numFiles - path := "/fs/file" + string(rune('0'+fileIdx)) + ".txt" - - handle, err := mfs.OpenHandle(path, filesystem.O_RDWR, 0644) - if err != nil { - t.Errorf("Goroutine %d: Failed to open handle %d: %v", goroutineID, i, err) - return - } - - localIDs = append(localIDs, handle.ID()) - - // Close the handle immediately to test close/reopen scenarios - // Note: ID should NOT be reused - if i%2 == 0 { - handle.Close() - } - } - - // Add to global collection - mu.Lock() - allIDs = append(allIDs, localIDs...) - mu.Unlock() - }(g) - } - - wg.Wait() - - // Verify all IDs are unique - expectedCount := numGoroutines * handlesPerGoroutine - if len(allIDs) != expectedCount { - t.Errorf("Expected %d handle IDs, got %d", expectedCount, len(allIDs)) - } - - // Check for duplicates - idSet := make(map[int64]bool) - duplicates := make([]int64, 0) - - for _, id := range allIDs { - if idSet[id] { - duplicates = append(duplicates, id) - } - idSet[id] = true - } - - if len(duplicates) > 0 { - t.Errorf("Found %d duplicate handle IDs: %v", len(duplicates), duplicates) - } - - // Verify IDs are in expected range [1, expectedCount] - for id := range idSet { - if id < 1 || id > int64(expectedCount) { - t.Errorf("Handle ID %d is out of expected range [1, %d]", id, expectedCount) - } - } - - t.Logf("Successfully generated %d unique handle IDs concurrently", len(allIDs)) - t.Logf("ID range: [%d, %d]", 1, expectedCount) -} - -// TestHandleIDNeverReused tests that closed handle IDs are never reused -func TestHandleIDNeverReused(t *testing.T) { - mfs := NewMountableFS(api.PoolConfig{}) - - plugin := memfs.NewMemFSPlugin() - err := plugin.Initialize(map[string]interface{}{}) - if err != nil { - t.Fatalf("Failed to initialize plugin: %v", err) - } - - err = mfs.Mount("/fs", plugin) - if err != nil { - t.Fatalf("Failed to mount fs: %v", err) - } - - fs := plugin.GetFileSystem().(*memfs.MemoryFS) - err = fs.Create("/test.txt") - if err != nil { - t.Fatalf("Failed to create file: %v", err) - } - - // Open and close handles multiple times - seenIDs := make(map[int64]bool) - numIterations := 100 - - for i := 0; i < numIterations; i++ { - handle, err := mfs.OpenHandle("/fs/test.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Fatalf("Iteration %d: Failed to open handle: %v", i, err) - } - - id := handle.ID() - - // Check that this ID has never been seen before - if seenIDs[id] { - t.Fatalf("Handle ID %d was reused on iteration %d!", id, i) - } - seenIDs[id] = true - - // Close the handle - err = handle.Close() - if err != nil { - t.Fatalf("Iteration %d: Failed to close handle: %v", i, err) - } - } - - // Verify we got a strictly increasing sequence - expectedIDs := make([]int64, numIterations) - for i := 0; i < numIterations; i++ { - expectedIDs[i] = int64(i + 1) - } - - for _, expectedID := range expectedIDs { - if !seenIDs[expectedID] { - t.Errorf("Expected to see handle ID %d, but it was not generated", expectedID) - } - } - - t.Logf("Successfully verified that %d sequential handle IDs were never reused", numIterations) -} - -// TestMultipleMountsHandleIDUniqueness tests handle ID uniqueness across multiple mounts -func TestMultipleMountsHandleIDUniqueness(t *testing.T) { - mfs := NewMountableFS(api.PoolConfig{}) - - // Create and mount multiple memfs instances - numMounts := 10 - plugins := make([]filesystem.FileSystem, numMounts) - - for i := 0; i < numMounts; i++ { - plugin := memfs.NewMemFSPlugin() - err := plugin.Initialize(map[string]interface{}{}) - if err != nil { - t.Fatalf("Failed to initialize plugin %d: %v", i, err) - } - - mountPath := "/fs" + string(rune('0'+i)) - err = mfs.Mount(mountPath, plugin) - if err != nil { - t.Fatalf("Failed to mount fs%d: %v", i, err) - } - - fs := plugin.GetFileSystem().(*memfs.MemoryFS) - err = fs.Create("/test.txt") - if err != nil { - t.Fatalf("Failed to create file in fs%d: %v", i, err) - } - - plugins[i] = fs - } - - // Open handles from all mounts concurrently - var wg sync.WaitGroup - var mu sync.Mutex - allIDs := make([]int64, 0, numMounts*10) - - for i := 0; i < numMounts; i++ { - wg.Add(1) - go func(mountIdx int) { - defer wg.Done() - - mountPath := "/fs" + string(rune('0'+mountIdx)) - - // Open 10 handles from this mount - for j := 0; j < 10; j++ { - handle, err := mfs.OpenHandle(mountPath+"/test.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Errorf("Mount %d: Failed to open handle %d: %v", mountIdx, j, err) - return - } - - mu.Lock() - allIDs = append(allIDs, handle.ID()) - mu.Unlock() - - // Keep some handles open, close others - if j%3 == 0 { - handle.Close() - } - } - }(i) - } - - wg.Wait() - - // Verify all IDs are unique - idSet := make(map[int64]bool) - for _, id := range allIDs { - if idSet[id] { - t.Errorf("Duplicate handle ID found: %d", id) - } - idSet[id] = true - } - - t.Logf("Generated %d unique handle IDs across %d mounts", len(allIDs), numMounts) -} diff --git a/third_party/agfs/agfs-server/pkg/mountablefs/handle_test.go b/third_party/agfs/agfs-server/pkg/mountablefs/handle_test.go deleted file mode 100644 index 977424648..000000000 --- a/third_party/agfs/agfs-server/pkg/mountablefs/handle_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package mountablefs - -import ( - "testing" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/memfs" -) - -// TestGlobalHandleIDUniqueness tests that handle IDs are globally unique -// across multiple mounted plugin instances, even when plugins generate -// conflicting local handle IDs -func TestGlobalHandleIDUniqueness(t *testing.T) { - // Create MountableFS with multiple MemoryFS instances mounted - mfs := NewMountableFS(api.PoolConfig{}) - - // Create two separate MemFS plugin instances - // Each will have its own MemoryFS with independent handle ID counters - plugin1 := memfs.NewMemFSPlugin() - plugin2 := memfs.NewMemFSPlugin() - - // Initialize them - err := plugin1.Initialize(map[string]interface{}{}) - if err != nil { - t.Fatalf("Failed to initialize plugin1: %v", err) - } - err = plugin2.Initialize(map[string]interface{}{}) - if err != nil { - t.Fatalf("Failed to initialize plugin2: %v", err) - } - - // Get the underlying MemoryFS instances - memfs1 := plugin1.GetFileSystem().(*memfs.MemoryFS) - memfs2 := plugin2.GetFileSystem().(*memfs.MemoryFS) - - // Mount at different paths - err = mfs.Mount("/fs1", plugin1) - if err != nil { - t.Fatalf("Failed to mount fs1: %v", err) - } - - err = mfs.Mount("/fs2", plugin2) - if err != nil { - t.Fatalf("Failed to mount fs2: %v", err) - } - - // Create files in both filesystems - err = memfs1.Create("/test1.txt") - if err != nil { - t.Fatalf("Failed to create file in fs1: %v", err) - } - - err = memfs2.Create("/test2.txt") - if err != nil { - t.Fatalf("Failed to create file in fs2: %v", err) - } - - // Open handles in both filesystems - // Both underlying filesystems will generate local handle ID = 1 - handle1, err := mfs.OpenHandle("/fs1/test1.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Fatalf("Failed to open handle in fs1: %v", err) - } - defer handle1.Close() - - handle2, err := mfs.OpenHandle("/fs2/test2.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Fatalf("Failed to open handle in fs2: %v", err) - } - defer handle2.Close() - - // Verify that the global IDs are different - id1 := handle1.ID() - id2 := handle2.ID() - - if id1 == id2 { - t.Errorf("Handle IDs should be globally unique, but both are %d", id1) - } - - t.Logf("Handle 1 global ID: %d, Handle 2 global ID: %d", id1, id2) - - // Verify we can retrieve handles by their global IDs - retrieved1, err := mfs.GetHandle(id1) - if err != nil { - t.Fatalf("Failed to retrieve handle 1: %v", err) - } - if retrieved1.ID() != id1 { - t.Errorf("Retrieved handle 1 has wrong ID: expected %d, got %d", id1, retrieved1.ID()) - } - - retrieved2, err := mfs.GetHandle(id2) - if err != nil { - t.Fatalf("Failed to retrieve handle 2: %v", err) - } - if retrieved2.ID() != id2 { - t.Errorf("Retrieved handle 2 has wrong ID: expected %d, got %d", id2, retrieved2.ID()) - } - - // Verify paths are correct - if retrieved1.Path() != "/fs1/test1.txt" { - t.Errorf("Handle 1 path incorrect: expected /fs1/test1.txt, got %s", retrieved1.Path()) - } - if retrieved2.Path() != "/fs2/test2.txt" { - t.Errorf("Handle 2 path incorrect: expected /fs2/test2.txt, got %s", retrieved2.Path()) - } - - // Test that we can write and read through the handles - testData1 := []byte("data from fs1") - n, err := handle1.Write(testData1) - if err != nil { - t.Fatalf("Failed to write to handle 1: %v", err) - } - if n != len(testData1) { - t.Errorf("Write to handle 1: expected %d bytes, wrote %d", len(testData1), n) - } - - testData2 := []byte("data from fs2") - n, err = handle2.Write(testData2) - if err != nil { - t.Fatalf("Failed to write to handle 2: %v", err) - } - if n != len(testData2) { - t.Errorf("Write to handle 2: expected %d bytes, wrote %d", len(testData2), n) - } - - // Seek back to beginning - _, err = handle1.Seek(0, 0) - if err != nil { - t.Fatalf("Failed to seek handle 1: %v", err) - } - _, err = handle2.Seek(0, 0) - if err != nil { - t.Fatalf("Failed to seek handle 2: %v", err) - } - - // Read back and verify - buf1 := make([]byte, len(testData1)) - n, err = handle1.Read(buf1) - if err != nil { - t.Fatalf("Failed to read from handle 1: %v", err) - } - if string(buf1[:n]) != string(testData1) { - t.Errorf("Read from handle 1: expected %s, got %s", testData1, buf1[:n]) - } - - buf2 := make([]byte, len(testData2)) - n, err = handle2.Read(buf2) - if err != nil { - t.Fatalf("Failed to read from handle 2: %v", err) - } - if string(buf2[:n]) != string(testData2) { - t.Errorf("Read from handle 2: expected %s, got %s", testData2, buf2[:n]) - } - - // Close handles - err = mfs.CloseHandle(id1) - if err != nil { - t.Fatalf("Failed to close handle 1: %v", err) - } - - err = mfs.CloseHandle(id2) - if err != nil { - t.Fatalf("Failed to close handle 2: %v", err) - } - - // Verify handles are no longer accessible - _, err = mfs.GetHandle(id1) - if err != filesystem.ErrNotFound { - t.Errorf("Expected ErrNotFound for closed handle 1, got: %v", err) - } - - _, err = mfs.GetHandle(id2) - if err != filesystem.ErrNotFound { - t.Errorf("Expected ErrNotFound for closed handle 2, got: %v", err) - } -} - -// TestMultipleHandlesSameFile tests opening multiple handles to the same file -func TestMultipleHandlesSameFile(t *testing.T) { - mfs := NewMountableFS(api.PoolConfig{}) - - plugin1 := memfs.NewMemFSPlugin() - err := plugin1.Initialize(map[string]interface{}{}) - if err != nil { - t.Fatalf("Failed to initialize plugin: %v", err) - } - - err = mfs.Mount("/fs", plugin1) - if err != nil { - t.Fatalf("Failed to mount fs: %v", err) - } - - // Get the underlying MemoryFS - memfs1 := plugin1.GetFileSystem().(*memfs.MemoryFS) - - // Create a file - err = memfs1.Create("/shared.txt") - if err != nil { - t.Fatalf("Failed to create file: %v", err) - } - - // Open multiple handles to the same file - handle1, err := mfs.OpenHandle("/fs/shared.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Fatalf("Failed to open handle 1: %v", err) - } - defer handle1.Close() - - handle2, err := mfs.OpenHandle("/fs/shared.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Fatalf("Failed to open handle 2: %v", err) - } - defer handle2.Close() - - handle3, err := mfs.OpenHandle("/fs/shared.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("Failed to open handle 3: %v", err) - } - defer handle3.Close() - - // All handles should have different global IDs - ids := []int64{handle1.ID(), handle2.ID(), handle3.ID()} - for i := 0; i < len(ids); i++ { - for j := i + 1; j < len(ids); j++ { - if ids[i] == ids[j] { - t.Errorf("Handles %d and %d have same ID: %d", i, j, ids[i]) - } - } - } - - t.Logf("Three handles to same file have IDs: %v", ids) - - // Verify all handles point to the same file - for i, h := range []filesystem.FileHandle{handle1, handle2, handle3} { - if h.Path() != "/fs/shared.txt" { - t.Errorf("Handle %d has wrong path: %s", i, h.Path()) - } - } -} diff --git a/third_party/agfs/agfs-server/pkg/mountablefs/mountablefs.go b/third_party/agfs/agfs-server/pkg/mountablefs/mountablefs.go deleted file mode 100644 index 713c2920e..000000000 --- a/third_party/agfs/agfs-server/pkg/mountablefs/mountablefs.go +++ /dev/null @@ -1,967 +0,0 @@ -package mountablefs - -import ( - "fmt" - "io" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/loader" - iradix "github.com/hashicorp/go-immutable-radix" - log "github.com/sirupsen/logrus" -) - -// Meta values for MountableFS -const ( - MetaValueRoot = "root" - MetaValueMountPoint = "mount-point" -) - -// MountPoint represents a mounted service plugin -type MountPoint struct { - Path string - Plugin plugin.ServicePlugin - Config map[string]interface{} // Plugin configuration -} - -// PluginFactory is a function that creates a new plugin instance -type PluginFactory func() plugin.ServicePlugin - -// MountableFS is a FileSystem that supports mounting service plugins at specific paths -type MountableFS struct { - // mountTree stores the radix tree for mount routing. - // We use atomic.Value to store *iradix.Tree to enable lock-free reads. - mountTree atomic.Value - - pluginFactories map[string]PluginFactory - pluginLoader *loader.PluginLoader // For loading external plugins - pluginNameCounters map[string]int // Track counters for plugin names - mu sync.RWMutex // Protects write operations (Mount/Unmount) and plugin factories - - // Global handle ID management (prevents conflicts across multiple plugin instances) - globalHandleID atomic.Int64 // Atomic counter for generating globally unique handle IDs - - // handleInfo stores the mapping between global handle IDs and underlying handles - // Key: global handle ID (generated by MountableFS) - // Value: handleInfo containing mount point and local handle ID - handleInfos map[int64]*handleInfo - handleInfosMu sync.RWMutex -} - -// handleInfo stores information about a handle, including its mount point and local handle -type handleInfo struct { - mount *MountPoint // The mount point where this handle was opened - localHandle filesystem.FileHandle // The underlying handle from the plugin -} - -// NewMountableFS creates a new mountable file system with the specified WASM pool configuration -func NewMountableFS(poolConfig api.PoolConfig) *MountableFS { - mfs := &MountableFS{ - pluginFactories: make(map[string]PluginFactory), - pluginLoader: loader.NewPluginLoader(poolConfig), - pluginNameCounters: make(map[string]int), - handleInfos: make(map[int64]*handleInfo), - } - mfs.mountTree.Store(iradix.New()) - // Start global handle IDs from 1 - mfs.globalHandleID.Store(0) - return mfs -} - -// GetPluginLoader returns the plugin loader instance -func (mfs *MountableFS) GetPluginLoader() *loader.PluginLoader { - return mfs.pluginLoader -} - -// RenamedPlugin wraps a plugin with a different name -type RenamedPlugin struct { - plugin.ServicePlugin - originalName string - renamedName string -} - -// Name returns the renamed plugin name -func (rp *RenamedPlugin) Name() string { - return rp.renamedName -} - -// OriginalName returns the original plugin name -func (rp *RenamedPlugin) OriginalName() string { - return rp.originalName -} - -// generateUniquePluginName generates a unique plugin name with incremental suffix -// Must be called with mfs.mu held (write lock) -func (mfs *MountableFS) generateUniquePluginName(baseName string) string { - // Check if base name is available - if _, exists := mfs.pluginFactories[baseName]; !exists { - // Base name is available, initialize counter - mfs.pluginNameCounters[baseName] = 0 - return baseName - } - - // Base name exists, increment counter and generate new name - mfs.pluginNameCounters[baseName]++ - counter := mfs.pluginNameCounters[baseName] - newName := fmt.Sprintf("%s-%d", baseName, counter) - - // Ensure the generated name doesn't conflict (defensive programming) - for { - if _, exists := mfs.pluginFactories[newName]; !exists { - return newName - } - mfs.pluginNameCounters[baseName]++ - counter = mfs.pluginNameCounters[baseName] - newName = fmt.Sprintf("%s-%d", baseName, counter) - } -} - -// RegisterPluginFactory registers a plugin factory for dynamic mounting -func (mfs *MountableFS) RegisterPluginFactory(name string, factory PluginFactory) { - mfs.mu.Lock() - defer mfs.mu.Unlock() - mfs.pluginFactories[name] = factory -} - -// CreatePlugin creates a plugin instance from a registered factory -func (mfs *MountableFS) CreatePlugin(name string) plugin.ServicePlugin { - mfs.mu.RLock() - defer mfs.mu.RUnlock() - - factory, ok := mfs.pluginFactories[name] - if !ok { - return nil - } - return factory() -} - -// Mount mounts a service plugin at the specified path -func (mfs *MountableFS) Mount(path string, plugin plugin.ServicePlugin) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - // Normalize path - path = filesystem.NormalizePath(path) - - // Load current tree - tree := mfs.mountTree.Load().(*iradix.Tree) - - // Check if path is already mounted - if _, exists := tree.Get([]byte(path)); exists { - return filesystem.NewAlreadyExistsError("mount", path) - } - - // Special handling for plugins that need parent filesystem reference - type parentFSSetter interface { - SetParentFileSystem(filesystem.FileSystem) - } - if setter, ok := plugin.(parentFSSetter); ok { - setter.SetParentFileSystem(mfs) - log.Debugf("Set parentFS for plugin at %s", path) - } - - // Create new tree with added mount - newTree, _, _ := tree.Insert([]byte(path), &MountPoint{ - Path: path, - Plugin: plugin, - Config: make(map[string]interface{}), - }) - - // Atomically update tree - mfs.mountTree.Store(newTree) - - return nil -} - -// MountPlugin dynamically mounts a plugin at the specified path -func (mfs *MountableFS) MountPlugin(fstype string, path string, config map[string]interface{}) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - // Normalize path - path = filesystem.NormalizePath(path) - - // Load current tree - tree := mfs.mountTree.Load().(*iradix.Tree) - - // Check if path is already mounted - if _, exists := tree.Get([]byte(path)); exists { - return filesystem.NewAlreadyExistsError("mount", path) - } - - // Get plugin factory - factory, ok := mfs.pluginFactories[fstype] - if !ok { - return fmt.Errorf("unknown filesystem type: %s", fstype) - } - - // Create plugin instance - pluginInstance := factory() - - // Special handling for plugins that need rootFS reference - type rootFSSetter interface { - SetRootFS(filesystem.FileSystem) - } - if setter, ok := pluginInstance.(rootFSSetter); ok { - setter.SetRootFS(mfs) - log.Debugf("Set rootFS for plugin %s at %s", fstype, path) - } - - // Special handling for plugins that need parent filesystem reference - type parentFSSetter interface { - SetParentFileSystem(filesystem.FileSystem) - } - if setter, ok := pluginInstance.(parentFSSetter); ok { - setter.SetParentFileSystem(mfs) - log.Debugf("Set parentFS for plugin %s at %s", fstype, path) - } - - // Inject mount_path into config - configWithPath := make(map[string]interface{}) - for k, v := range config { - configWithPath[k] = v - } - configWithPath["mount_path"] = path - - // Validate plugin configuration - if err := pluginInstance.Validate(configWithPath); err != nil { - return fmt.Errorf("failed to validate plugin: %v", err) - } - - // Initialize plugin with config - if err := pluginInstance.Initialize(configWithPath); err != nil { - return fmt.Errorf("failed to initialize plugin: %v", err) - } - - // Create new tree with added mount - newTree, _, _ := tree.Insert([]byte(path), &MountPoint{ - Path: path, - Plugin: pluginInstance, - Config: config, - }) - - // Atomically update tree - mfs.mountTree.Store(newTree) - - log.Infof("mounted %s at %s", fstype, path) - return nil -} - -// Unmount unmounts a plugin from the specified path -func (mfs *MountableFS) Unmount(path string) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - path = filesystem.NormalizePath(path) - - // Load current tree - tree := mfs.mountTree.Load().(*iradix.Tree) - - val, exists := tree.Get([]byte(path)) - if !exists { - return fmt.Errorf("no mount at path: %s", path) - } - mount := val.(*MountPoint) - - // Shutdown the plugin - if err := mount.Plugin.Shutdown(); err != nil { - return fmt.Errorf("failed to shutdown plugin: %v", err) - } - - // Create new tree without the mount - newTree, _, _ := tree.Delete([]byte(path)) - - // Atomically update tree - mfs.mountTree.Store(newTree) - - log.Infof("Unmounted plugin at %s", path) - return nil -} - -// LoadExternalPluginWithType loads a plugin with an explicitly specified type -func (mfs *MountableFS) LoadExternalPluginWithType(libraryPath string, pluginType loader.PluginType) (plugin.ServicePlugin, error) { - // For WASM plugins, pass MountableFS as host filesystem to allow access to all agfs paths - var p plugin.ServicePlugin - var err error - if pluginType == loader.PluginTypeWASM { - log.Infof("Loading WASM plugin with host filesystem access to all agfs paths") - p, err = mfs.pluginLoader.LoadPluginWithType(libraryPath, pluginType, mfs) - } else { - p, err = mfs.pluginLoader.LoadPluginWithType(libraryPath, pluginType) - } - if err != nil { - return nil, err - } - - // Register the plugin as a factory so it can be mounted - pluginName := p.Name() - mfs.RegisterPluginFactory(pluginName, func() plugin.ServicePlugin { - return p - }) - - log.Infof("Registered external plugin factory: %s (type: %s)", pluginName, pluginType) - return p, nil -} - -// LoadExternalPlugin loads a plugin from a shared library file -func (mfs *MountableFS) LoadExternalPlugin(libraryPath string) (plugin.ServicePlugin, error) { - // Detect plugin type first - pluginType, err := loader.DetectPluginType(libraryPath) - if err != nil { - return nil, fmt.Errorf("failed to detect plugin type: %w", err) - } - - if pluginType == loader.PluginTypeWASM { - return mfs.LoadExternalPluginWithType(libraryPath, pluginType) - } - - p, err := mfs.pluginLoader.LoadPlugin(libraryPath) - if err != nil { - return nil, err - } - - originalName := p.Name() - - mfs.mu.Lock() - - finalName := mfs.generateUniquePluginName(originalName) - renamed := (finalName != originalName) - - if renamed { - log.Infof("Plugin name '%s' already exists, using '%s' instead", originalName, finalName) - } - - var pluginToRegister plugin.ServicePlugin = p - if renamed { - pluginToRegister = &RenamedPlugin{ - ServicePlugin: p, - originalName: originalName, - renamedName: finalName, - } - } - - mfs.pluginFactories[finalName] = func() plugin.ServicePlugin { - return pluginToRegister - } - - mfs.mu.Unlock() - - log.Infof("Registered external plugin factory: %s", finalName) - - if renamed { - return &RenamedPlugin{ - ServicePlugin: p, - originalName: originalName, - renamedName: finalName, - }, nil - } - - return p, nil -} - -// UnloadExternalPluginWithType unloads an external plugin with an explicitly specified type -func (mfs *MountableFS) UnloadExternalPluginWithType(libraryPath string, pluginType loader.PluginType) error { - return mfs.pluginLoader.UnloadPluginWithType(libraryPath, pluginType) -} - -// UnloadExternalPlugin unloads an external plugin -func (mfs *MountableFS) UnloadExternalPlugin(libraryPath string) error { - return mfs.pluginLoader.UnloadPlugin(libraryPath) -} - -// GetLoadedExternalPlugins returns a list of loaded external plugin paths -func (mfs *MountableFS) GetLoadedExternalPlugins() []string { - return mfs.pluginLoader.GetLoadedPlugins() -} - -// GetPluginNameToPathMap returns a map of plugin names to their library paths -func (mfs *MountableFS) GetPluginNameToPathMap() map[string]string { - return mfs.pluginLoader.GetPluginNameToPathMap() -} - -// GetBuiltinPluginNames returns a list of all registered builtin plugin names -func (mfs *MountableFS) GetBuiltinPluginNames() []string { - mfs.mu.RLock() - defer mfs.mu.RUnlock() - - externalPlugins := mfs.pluginLoader.GetPluginNameToPathMap() - - names := make([]string, 0) - for name := range mfs.pluginFactories { - if _, isExternal := externalPlugins[name]; !isExternal { - names = append(names, name) - } - } - return names -} - -// LoadExternalPluginsFromDirectory loads all plugins from a directory -func (mfs *MountableFS) LoadExternalPluginsFromDirectory(dir string) ([]string, []error) { - return mfs.pluginLoader.LoadPluginsFromDirectory(dir) -} - -// GetMounts returns all mount points -func (mfs *MountableFS) GetMounts() []*MountPoint { - // Lock-free read - tree := mfs.mountTree.Load().(*iradix.Tree) - - var mounts []*MountPoint - tree.Root().Walk(func(k []byte, v interface{}) bool { - mounts = append(mounts, v.(*MountPoint)) - return false - }) - return mounts -} - -// findMount finds the mount point for a given path using lock-free radix tree lookup -// Returns the mount and the relative path within the mount -func (mfs *MountableFS) findMount(path string) (*MountPoint, string, bool) { - path = filesystem.NormalizePath(path) - - // Lock-free read - tree := mfs.mountTree.Load().(*iradix.Tree) - - // LongestPrefix match - k, v, found := tree.Root().LongestPrefix([]byte(path)) - if !found { - return nil, "", false - } - - mountPath := string(k) - - // Verify prefix boundary to ensure we don't match "/mnt-foo" against "/mnt" - // 1. Exact match - if len(path) == len(mountPath) { - mount := v.(*MountPoint) - return mount, "/", true - } - - // 2. Subdirectory match (path must start with mountPath + "/") - // Case A: mountPath is "/" -> path matches "/..." which is correct - if mountPath == "/" { - mount := v.(*MountPoint) - return mount, path, true - } - - // Case B: mountPath is "/mnt" -> path must be "/mnt/..." - if len(path) > len(mountPath) && path[len(mountPath)] == '/' { - mount := v.(*MountPoint) - relPath := path[len(mountPath):] - return mount, relPath, true - } - - // Partial match failed (e.g. "/mnt-foo" matched "/mnt") - return nil, "", false -} - -// Delegate all FileSystem methods to either base FS or mounted plugin - -func (mfs *MountableFS) Create(path string) error { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Create(relPath) - } - return filesystem.NewPermissionDeniedError("create", path, "not allowed to create file in rootfs, use mount instead") -} - -func (mfs *MountableFS) Mkdir(path string, perm uint32) error { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Mkdir(relPath, perm) - } - return filesystem.NewPermissionDeniedError("mkdir", path, "not allowed to create directory in rootfs, use mount instead") -} - -func (mfs *MountableFS) Remove(path string) error { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Remove(relPath) - } - return filesystem.NewNotFoundError("remove", path) -} - -func (mfs *MountableFS) RemoveAll(path string) error { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().RemoveAll(relPath) - } - return filesystem.NewNotFoundError("removeall", path) -} - -func (mfs *MountableFS) Read(path string, offset int64, size int64) ([]byte, error) { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Read(relPath, offset, size) - } - return nil, filesystem.NewNotFoundError("read", path) -} - -func (mfs *MountableFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Write(relPath, data, offset, flags) - } - return 0, filesystem.NewNotFoundError("write", path) -} - -func (mfs *MountableFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - // Lock-free implementation - path = filesystem.NormalizePath(path) - - // 1. Check if we are listing a directory inside a mount - mount, relPath, found := mfs.findMount(path) - if found { - // Get contents from the mounted filesystem - infos, err := mount.Plugin.GetFileSystem().ReadDir(relPath) - if err != nil { - return nil, err - } - - // Also check for any nested mounts directly under this path - // e.g. mounted at /mnt, and we have /mnt/foo mounted - tree := mfs.mountTree.Load().(*iradix.Tree) - - // We want to find all mounts that are strictly children of `path` - // e.g. path="/mnt", mount="/mnt/foo" -> prefix match "/mnt/" - prefix := path - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - - // Walk prefix to find direct children - tree.Root().WalkPrefix([]byte(prefix), func(k []byte, v interface{}) bool { - mountPath := string(k) - // Only show direct children - // e.g. prefix="/mnt/", mountPath="/mnt/foo" -> OK - // mountPath="/mnt/foo/bar" -> SKIP (will be shown when listing /mnt/foo) - - rel := strings.TrimPrefix(mountPath, prefix) - if !strings.Contains(rel, "/") && rel != "" { - // Avoid duplicates if the plugin already reported it - exists := false - for _, info := range infos { - if info.Name == rel { - exists = true - break - } - } - if !exists { - infos = append(infos, filesystem.FileInfo{ - Name: rel, - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Type: MetaValueMountPoint, - }, - }) - } - } - return false - }) - - return infos, nil - } - - // 2. We are not in a mount, so we are listing the virtual root or intermediate directories - tree := mfs.mountTree.Load().(*iradix.Tree) - var infos []filesystem.FileInfo - seenDirs := make(map[string]bool) - - prefix := path - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - - // Walk all keys that have this prefix - tree.Root().WalkPrefix([]byte(prefix), func(k []byte, v interface{}) bool { - mountPath := string(k) - - // Extract the next directory component - rel := strings.TrimPrefix(mountPath, prefix) - if rel == "" { - return false // Should not happen if path logic is correct (path is not a mount) - } - - parts := strings.SplitN(rel, "/", 2) - nextDir := parts[0] - - if !seenDirs[nextDir] { - seenDirs[nextDir] = true - infos = append(infos, filesystem.FileInfo{ - Name: nextDir, - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: "rootfs", - Type: MetaValueMountPoint, - }, - }) - } - return false - }) - - if len(infos) > 0 { - return infos, nil - } - - return nil, filesystem.NewNotFoundError("readdir", path) -} - -func (mfs *MountableFS) Stat(path string) (*filesystem.FileInfo, error) { - path = filesystem.NormalizePath(path) - - // Check if path is root - if path == "/" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Type: MetaValueRoot, - }, - }, nil - } - - // Check if path is a mount point or within a mount - mount, relPath, found := mfs.findMount(path) - if found { - stat, err := mount.Plugin.GetFileSystem().Stat(relPath) - if err != nil { - return nil, err - } - - // Fix name if querying the mount point itself - if path == mount.Path && stat.Name == "/" { - name := path[1:] - if lastSlash := strings.LastIndex(name, "/"); lastSlash >= 0 { - name = name[lastSlash+1:] - } - if name == "" { - name = "/" - } - stat.Name = name - } - - return stat, nil - } - - // Check if path is a parent directory of any mount points - // e.g. /mnt when /mnt/foo exists - tree := mfs.mountTree.Load().(*iradix.Tree) - prefix := path - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - - isParent := false - tree.Root().WalkPrefix([]byte(prefix), func(k []byte, v interface{}) bool { - isParent = true - return true // Stop iteration - }) - - if isParent { - name := path[1:] - if lastSlash := strings.LastIndex(name, "/"); lastSlash >= 0 { - name = name[lastSlash+1:] - } - if name == "" { - name = "/" - } - return &filesystem.FileInfo{ - Name: name, - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Type: MetaValueMountPoint, - }, - }, nil - } - - return nil, filesystem.NewNotFoundError("stat", path) -} - -func (mfs *MountableFS) Rename(oldPath, newPath string) error { - // findMount is now lock-free - oldMount, oldRelPath, oldFound := mfs.findMount(oldPath) - newMount, newRelPath, newFound := mfs.findMount(newPath) - - if oldFound && newFound { - if oldMount != newMount { - return fmt.Errorf("cannot rename across different mounts") - } - return oldMount.Plugin.GetFileSystem().Rename(oldRelPath, newRelPath) - } - - return fmt.Errorf("cannot rename: paths not in same mounted filesystem") -} - -func (mfs *MountableFS) Chmod(path string, mode uint32) error { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Chmod(relPath, mode) - } - return filesystem.NewNotFoundError("chmod", path) -} - -// Touch implements filesystem.Toucher interface -func (mfs *MountableFS) Touch(path string) error { - mount, relPath, found := mfs.findMount(path) - - if found { - fs := mount.Plugin.GetFileSystem() - if toucher, ok := fs.(filesystem.Toucher); ok { - return toucher.Touch(relPath) - } - info, err := fs.Stat(relPath) - if err == nil { - if !info.IsDir { - data, readErr := fs.Read(relPath, 0, -1) - if readErr != nil { - return readErr - } - _, writeErr := fs.Write(relPath, data, -1, filesystem.WriteFlagNone) - return writeErr - } - return fmt.Errorf("cannot touch directory") - } else { - _, err := fs.Write(relPath, []byte{}, -1, filesystem.WriteFlagCreate) - return err - } - } - return filesystem.NewNotFoundError("touch", path) -} - -func (mfs *MountableFS) Open(path string) (io.ReadCloser, error) { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().Open(relPath) - } - return nil, filesystem.NewNotFoundError("open", path) -} - -func (mfs *MountableFS) OpenWrite(path string) (io.WriteCloser, error) { - mount, relPath, found := mfs.findMount(path) - - if found { - return mount.Plugin.GetFileSystem().OpenWrite(relPath) - } - return nil, filesystem.NewNotFoundError("openwrite", path) -} - -// OpenStream implements filesystem.Streamer interface -func (mfs *MountableFS) OpenStream(path string) (filesystem.StreamReader, error) { - mount, relPath, found := mfs.findMount(path) - - if !found { - return nil, filesystem.NewNotFoundError("openstream", path) - } - - fs := mount.Plugin.GetFileSystem() - if streamer, ok := fs.(filesystem.Streamer); ok { - log.Debugf("[mountablefs] OpenStream: found streamer for path %s (relPath: %s, fs type: %T)", path, relPath, fs) - return streamer.OpenStream(relPath) - } - - log.Debugf("[mountablefs] OpenStream: filesystem does not support streaming: %s (fs type: %T)", path, fs) - return nil, fmt.Errorf("filesystem does not support streaming: %s", path) -} - -// GetStream tries to get a stream from the underlying filesystem if it supports streaming -// Deprecated: Use OpenStream instead -func (mfs *MountableFS) GetStream(path string) (interface{}, error) { - mount, relPath, found := mfs.findMount(path) - - if !found { - return nil, filesystem.NewNotFoundError("getstream", path) - } - - type streamGetter interface { - GetStream(path string) (interface{}, error) - } - - fs := mount.Plugin.GetFileSystem() - if sg, ok := fs.(streamGetter); ok { - log.Debugf("[mountablefs] GetStream: found stream getter for path %s (relPath: %s, fs type: %T)", path, relPath, fs) - return sg.GetStream(relPath) - } - - log.Warnf("[mountablefs] GetStream: filesystem does not support streaming: %s (fs type: %T)", path, fs) - return nil, fmt.Errorf("filesystem does not support streaming: %s", path) -} - -// ============================================================================ -// HandleFS Implementation -// ============================================================================ - -// OpenHandle opens a file and returns a handle for stateful operations -// This delegates to the underlying filesystem if it supports HandleFS -func (mfs *MountableFS) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - mount, relPath, found := mfs.findMount(path) - - if !found { - return nil, filesystem.NewNotFoundError("openhandle", path) - } - - fs := mount.Plugin.GetFileSystem() - handleFS, ok := fs.(filesystem.HandleFS) - if !ok { - return nil, filesystem.NewNotSupportedError("openhandle", path) - } - - // Open handle in the underlying filesystem - localHandle, err := handleFS.OpenHandle(relPath, flags, mode) - if err != nil { - return nil, err - } - - // Generate a globally unique handle ID - globalID := mfs.globalHandleID.Add(1) - - // Store the mapping: globalID -> (mount, localHandle) - mfs.handleInfosMu.Lock() - mfs.handleInfos[globalID] = &handleInfo{ - mount: mount, - localHandle: localHandle, - } - mfs.handleInfosMu.Unlock() - - // Return a wrapper that uses the global ID - return &globalFileHandle{ - globalID: globalID, - localHandle: localHandle, - mountPath: mount.Path, - fullPath: path, - }, nil -} - -// GetHandle retrieves an existing handle by its ID -func (mfs *MountableFS) GetHandle(id int64) (filesystem.FileHandle, error) { - // Look up the handle info using the global ID - mfs.handleInfosMu.RLock() - info, found := mfs.handleInfos[id] - mfs.handleInfosMu.RUnlock() - - if !found { - return nil, filesystem.ErrNotFound - } - - // Return a wrapper with the global ID - return &globalFileHandle{ - globalID: id, - localHandle: info.localHandle, - mountPath: info.mount.Path, - fullPath: info.mount.Path + info.localHandle.Path(), - }, nil -} - -// CloseHandle closes a handle by its ID -func (mfs *MountableFS) CloseHandle(id int64) error { - // Look up the handle info using the global ID - mfs.handleInfosMu.RLock() - info, found := mfs.handleInfos[id] - mfs.handleInfosMu.RUnlock() - - if !found { - return filesystem.ErrNotFound - } - - // Close the underlying local handle - err := info.localHandle.Close() - if err == nil { - // Remove from mapping - mfs.handleInfosMu.Lock() - delete(mfs.handleInfos, id) - mfs.handleInfosMu.Unlock() - } - - return err -} - -// globalFileHandle wraps a local file handle with a globally unique ID -// This prevents handle ID conflicts when multiple plugin instances are mounted -type globalFileHandle struct { - globalID int64 // Globally unique ID assigned by MountableFS - localHandle filesystem.FileHandle // Underlying handle from the plugin - mountPath string // Mount path for this handle - fullPath string // Full path including mount point -} - -// ID returns the globally unique handle ID -func (h *globalFileHandle) ID() int64 { - return h.globalID -} - -// Path returns the full path (including mount point) -func (h *globalFileHandle) Path() string { - return h.fullPath -} - -// Read delegates to the underlying handle -func (h *globalFileHandle) Read(buf []byte) (int, error) { - return h.localHandle.Read(buf) -} - -// ReadAt delegates to the underlying handle -func (h *globalFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - return h.localHandle.ReadAt(buf, offset) -} - -// Write delegates to the underlying handle -func (h *globalFileHandle) Write(data []byte) (int, error) { - return h.localHandle.Write(data) -} - -// WriteAt delegates to the underlying handle -func (h *globalFileHandle) WriteAt(data []byte, offset int64) (int, error) { - return h.localHandle.WriteAt(data, offset) -} - -// Seek delegates to the underlying handle -func (h *globalFileHandle) Seek(offset int64, whence int) (int64, error) { - return h.localHandle.Seek(offset, whence) -} - -// Sync delegates to the underlying handle -func (h *globalFileHandle) Sync() error { - return h.localHandle.Sync() -} - -// Close delegates to the underlying handle -func (h *globalFileHandle) Close() error { - return h.localHandle.Close() -} - -// Stat delegates to the underlying handle -func (h *globalFileHandle) Stat() (*filesystem.FileInfo, error) { - return h.localHandle.Stat() -} - -// Flags delegates to the underlying handle -func (h *globalFileHandle) Flags() filesystem.OpenFlag { - return h.localHandle.Flags() -} - -// Ensure MountableFS implements HandleFS interface -var _ filesystem.HandleFS = (*MountableFS)(nil) -var _ filesystem.FileHandle = (*globalFileHandle)(nil) \ No newline at end of file diff --git a/third_party/agfs/agfs-server/pkg/mountablefs/mountablefs_test.go b/third_party/agfs/agfs-server/pkg/mountablefs/mountablefs_test.go deleted file mode 100644 index eac92107d..000000000 --- a/third_party/agfs/agfs-server/pkg/mountablefs/mountablefs_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package mountablefs - -import ( - "testing" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" -) - -// MockPlugin implements plugin.ServicePlugin for testing -type MockPlugin struct { - name string -} - -func (p *MockPlugin) Name() string { - return p.name -} - -func (p *MockPlugin) Validate(cfg map[string]interface{}) error { - return nil -} - -func (p *MockPlugin) Initialize(cfg map[string]interface{}) error { - return nil -} - -func (p *MockPlugin) GetFileSystem() filesystem.FileSystem { - return nil -} - -func (p *MockPlugin) GetReadme() string { - return "Mock Plugin" -} - -func (p *MockPlugin) GetConfigParams() []plugin.ConfigParameter { - return nil -} - -func (p *MockPlugin) Shutdown() error { - return nil -} - -func TestMountableFSRouting(t *testing.T) { - mfs := NewMountableFS(api.PoolConfig{}) - - p1 := &MockPlugin{name: "plugin1"} - p2 := &MockPlugin{name: "plugin2"} - pRoot := &MockPlugin{name: "rootPlugin"} - - // Test 1: Basic Mount - err := mfs.Mount("/data", p1) - if err != nil { - t.Fatalf("Failed to mount: %v", err) - } - - // Test 2: Exact Match - mount, relPath, found := mfs.findMount("/data") - if !found { - t.Errorf("Expected to find mount at /data") - } - if mount.Plugin != p1 { - t.Errorf("Expected plugin1, got %s", mount.Plugin.Name()) - } - if relPath != "/" { - t.Errorf("Expected relPath /, got %s", relPath) - } - - // Test 3: Subpath Match - mount, relPath, found = mfs.findMount("/data/file.txt") - if !found { - t.Errorf("Expected to find mount at /data/file.txt") - } - if mount.Plugin != p1 { - t.Errorf("Expected plugin1, got %s", mount.Plugin.Name()) - } - if relPath != "/file.txt" { - t.Errorf("Expected relPath /file.txt, got %s", relPath) - } - - // Test 4: Partial Match (Should Fail) - mount, _, found = mfs.findMount("/dataset") - if found { - t.Errorf("Should NOT find mount for /dataset (partial match of /data)") - } - - // Test 5: Nested Mounts / Longest Prefix - err = mfs.Mount("/data/users", p2) - if err != nil { - t.Fatalf("Failed to mount nested: %v", err) - } - - // /data should still map to p1 - mount, _, found = mfs.findMount("/data/config") - if !found || mount.Plugin != p1 { - t.Errorf("Expected /data/config to map to plugin1") - } - - // /data/users should map to p2 - mount, relPath, found = mfs.findMount("/data/users/alice") - if !found { - t.Errorf("Expected to find mount at /data/users/alice") - } - if mount.Plugin != p2 { - t.Errorf("Expected plugin2, got %s", mount.Plugin.Name()) - } - if relPath != "/alice" { - t.Errorf("Expected relPath /alice, got %s", relPath) - } - - // Test 6: Root Mount - err = mfs.Mount("/", pRoot) - if err != nil { - t.Fatalf("Failed to mount root: %v", err) - } - - // /other should map to root - mount, relPath, found = mfs.findMount("/other/file") - if !found { - t.Errorf("Expected to find mount at /other/file") - } - if mount.Plugin != pRoot { - t.Errorf("Expected rootPlugin, got %s", mount.Plugin.Name()) - } - if relPath != "/other/file" { - t.Errorf("Expected relPath /other/file, got %s", relPath) - } - - // /data/users/alice should still map to p2 (longest match) - mount, _, found = mfs.findMount("/data/users/alice") - if !found || mount.Plugin != p2 { - t.Errorf("Root mount broke specific mount routing") - } - - // Test 7: Unmount - err = mfs.Unmount("/data") - if err != nil { - t.Fatalf("Failed to unmount: %v", err) - } - - // /data/file should now fall back to Root because /data is gone - mount, _, found = mfs.findMount("/data/file") - if !found { - t.Errorf("Expected /data/file to be found (fallback to root)") - } - if mount.Plugin != pRoot { - t.Errorf("Expected fallback to rootPlugin, got %s", mount.Plugin.Name()) - } - - // /data/users should still exist - mount, _, found = mfs.findMount("/data/users/bob") - if !found || mount.Plugin != p2 { - t.Errorf("Unmounting parent should not affect child mount") - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/api/bridge.go b/third_party/agfs/agfs-server/pkg/plugin/api/bridge.go deleted file mode 100644 index 46a08dbca..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/api/bridge.go +++ /dev/null @@ -1,353 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "io" - "time" - "unsafe" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" -) - -// NewExternalPlugin creates a new external plugin wrapper -func NewExternalPlugin(libHandle uintptr, vtable *PluginVTable) (*ExternalPlugin, error) { - if vtable.PluginNew == nil { - return nil, fmt.Errorf("plugin missing required PluginNew function") - } - - // Create plugin instance - pluginPtr := vtable.PluginNew() - if pluginPtr == nil { - return nil, fmt.Errorf("PluginNew returned null pointer") - } - - // Get plugin name - var name string - if vtable.PluginName != nil { - namePtr := vtable.PluginName(pluginPtr) - name = GoString(namePtr) - } - - ep := &ExternalPlugin{ - libHandle: libHandle, - pluginPtr: pluginPtr, - name: name, - vtable: vtable, - } - - // Create filesystem wrapper - ep.fileSystem = &ExternalFileSystem{ - pluginPtr: pluginPtr, - vtable: vtable, - } - - return ep, nil -} - -// Implement plugin.ServicePlugin interface - -func (ep *ExternalPlugin) Name() string { - return ep.name -} - -func (ep *ExternalPlugin) Validate(config map[string]interface{}) error { - if ep.vtable.PluginValidate == nil { - return nil // Validation not implemented - } - - // Convert config to JSON - configJSON, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - configCStr := CString(string(configJSON)) - errPtr := ep.vtable.PluginValidate(ep.pluginPtr, configCStr) - return GoError(errPtr) -} - -func (ep *ExternalPlugin) Initialize(config map[string]interface{}) error { - if ep.vtable.PluginInitialize == nil { - return nil // Initialization not required - } - - // Convert config to JSON - configJSON, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - configCStr := CString(string(configJSON)) - errPtr := ep.vtable.PluginInitialize(ep.pluginPtr, configCStr) - return GoError(errPtr) -} - -func (ep *ExternalPlugin) GetFileSystem() filesystem.FileSystem { - return ep.fileSystem -} - -func (ep *ExternalPlugin) GetReadme() string { - if ep.vtable.PluginGetReadme == nil { - return "" - } - - readmePtr := ep.vtable.PluginGetReadme(ep.pluginPtr) - return GoString(readmePtr) -} - -func (ep *ExternalPlugin) GetConfigParams() []plugin.ConfigParameter { - // External plugins (native .so/.dylib/.dll) don't expose config params via C API yet - // Return empty list for now - return []plugin.ConfigParameter{} -} - -func (ep *ExternalPlugin) Shutdown() error { - if ep.vtable.PluginShutdown == nil { - return nil - } - - errPtr := ep.vtable.PluginShutdown(ep.pluginPtr) - err := GoError(errPtr) - - // Free the plugin instance - if ep.vtable.PluginFree != nil { - ep.vtable.PluginFree(ep.pluginPtr) - } - - return err -} - -// Implement filesystem.FileSystem interface - -func (efs *ExternalFileSystem) Create(path string) error { - if efs.vtable.FSCreate == nil { - return fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - errPtr := efs.vtable.FSCreate(efs.pluginPtr, pathCStr) - return GoError(errPtr) -} - -func (efs *ExternalFileSystem) Mkdir(path string, perm uint32) error { - if efs.vtable.FSMkdir == nil { - return fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - errPtr := efs.vtable.FSMkdir(efs.pluginPtr, pathCStr, perm) - return GoError(errPtr) -} - -func (efs *ExternalFileSystem) Remove(path string) error { - if efs.vtable.FSRemove == nil { - return fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - errPtr := efs.vtable.FSRemove(efs.pluginPtr, pathCStr) - return GoError(errPtr) -} - -func (efs *ExternalFileSystem) RemoveAll(path string) error { - if efs.vtable.FSRemoveAll == nil { - return fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - errPtr := efs.vtable.FSRemoveAll(efs.pluginPtr, pathCStr) - return GoError(errPtr) -} - -func (efs *ExternalFileSystem) Read(path string, offset int64, size int64) ([]byte, error) { - if efs.vtable.FSRead == nil { - return nil, fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - var dataLen int - dataPtr := efs.vtable.FSRead(efs.pluginPtr, pathCStr, offset, size, &dataLen) - - if dataPtr == nil { - if dataLen < 0 { - return nil, fmt.Errorf("read failed") - } - return []byte{}, nil - } - - // Copy data from C to Go - data := make([]byte, dataLen) - for i := 0; i < dataLen; i++ { - ptr := unsafe.Pointer(uintptr(unsafe.Pointer(dataPtr)) + uintptr(i)) - data[i] = *(*byte)(ptr) - } - - return data, nil -} - -func (efs *ExternalFileSystem) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - if efs.vtable.FSWrite == nil { - return 0, fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - var dataCStr *byte - if len(data) > 0 { - dataCStr = &data[0] - } - - // Call C plugin with new signature: (plugin, path, data, len, offset, flags) -> int64 - bytesWritten := efs.vtable.FSWrite(efs.pluginPtr, pathCStr, dataCStr, len(data), offset, uint32(flags)) - if bytesWritten < 0 { - return 0, fmt.Errorf("write failed") - } - - return bytesWritten, nil -} - -func (efs *ExternalFileSystem) ReadDir(path string) ([]filesystem.FileInfo, error) { - if efs.vtable.FSReadDir == nil { - return nil, fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - var count int - arrPtr := efs.vtable.FSReadDir(efs.pluginPtr, pathCStr, &count) - - if arrPtr == nil || count == 0 { - return []filesystem.FileInfo{}, nil - } - - // Convert C array to Go slice - infos := make([]filesystem.FileInfo, count) - for i := 0; i < count; i++ { - cInfoPtr := unsafe.Pointer(uintptr(unsafe.Pointer(arrPtr.Items)) + uintptr(i)*unsafe.Sizeof(FileInfoC{})) - cInfo := (*FileInfoC)(cInfoPtr) - goInfo := FileInfoCToGo(cInfo) - if goInfo != nil { - infos[i] = *goInfo - } - } - - return infos, nil -} - -func (efs *ExternalFileSystem) Stat(path string) (*filesystem.FileInfo, error) { - if efs.vtable.FSStat == nil { - return nil, fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - cInfo := efs.vtable.FSStat(efs.pluginPtr, pathCStr) - - if cInfo == nil { - return nil, fmt.Errorf("stat failed") - } - - return FileInfoCToGo(cInfo), nil -} - -func (efs *ExternalFileSystem) Rename(oldPath, newPath string) error { - if efs.vtable.FSRename == nil { - return fmt.Errorf("not implemented") - } - - oldPathCStr := CString(oldPath) - newPathCStr := CString(newPath) - errPtr := efs.vtable.FSRename(efs.pluginPtr, oldPathCStr, newPathCStr) - return GoError(errPtr) -} - -func (efs *ExternalFileSystem) Chmod(path string, mode uint32) error { - if efs.vtable.FSChmod == nil { - return fmt.Errorf("not implemented") - } - - pathCStr := CString(path) - errPtr := efs.vtable.FSChmod(efs.pluginPtr, pathCStr, mode) - return GoError(errPtr) -} - -func (efs *ExternalFileSystem) Open(path string) (io.ReadCloser, error) { - // Default implementation using Read - data, err := efs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(io.NewSectionReader(&bytesReaderAt{data}, 0, int64(len(data)))), nil -} - -func (efs *ExternalFileSystem) OpenWrite(path string) (io.WriteCloser, error) { - return &writeCloser{fs: efs, path: path}, nil -} - -// Helper types - -type bytesReaderAt struct { - data []byte -} - -func (b *bytesReaderAt) ReadAt(p []byte, off int64) (n int, err error) { - if off >= int64(len(b.data)) { - return 0, io.EOF - } - n = copy(p, b.data[off:]) - if n < len(p) { - err = io.EOF - } - return -} - -type writeCloser struct { - fs *ExternalFileSystem - path string - buf []byte -} - -func (wc *writeCloser) Write(p []byte) (n int, err error) { - wc.buf = append(wc.buf, p...) - return len(p), nil -} - -func (wc *writeCloser) Close() error { - _, err := wc.fs.Write(wc.path, wc.buf, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - return err -} - -// FileInfoCToGo with proper time handling -func FileInfoCToGo(c *FileInfoC) *filesystem.FileInfo { - if c == nil { - return nil - } - - info := &filesystem.FileInfo{ - Name: GoString(c.Name), - Size: c.Size, - Mode: c.Mode, - ModTime: time.Unix(c.ModTime, 0), - IsDir: c.IsDir != 0, - Meta: filesystem.MetaData{ - Name: GoString(c.MetaName), - Type: GoString(c.MetaType), - Content: make(map[string]string), - }, - } - - // Parse MetaContent JSON if present - if c.MetaContent != nil { - contentStr := GoString(c.MetaContent) - if contentStr != "" { - json.Unmarshal([]byte(contentStr), &info.Meta.Content) - } - } - - return info -} - -// Ensure ExternalPlugin implements plugin.ServicePlugin -var _ plugin.ServicePlugin = (*ExternalPlugin)(nil) - -// Ensure ExternalFileSystem implements filesystem.FileSystem -var _ filesystem.FileSystem = (*ExternalFileSystem)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugin/api/host_fs.go b/third_party/agfs/agfs-server/pkg/plugin/api/host_fs.go deleted file mode 100644 index 301fd5ed2..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/api/host_fs.go +++ /dev/null @@ -1,340 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - log "github.com/sirupsen/logrus" - wazeroapi "github.com/tetratelabs/wazero/api" -) - -// Host function implementations for filesystem operations -// These functions are exported to WASM modules and allow them to access the host filesystem - -func HostFSRead(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - offset := int64(params[1]) - size := int64(params[2]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - log.Errorf("host_fs_read: failed to read path from memory") - return []uint64{0} // Return 0 to indicate error - } - - log.Debugf("host_fs_read: path=%s, offset=%d, size=%d", path, offset, size) - - // Check if filesystem is provided - if fs == nil { - log.Errorf("host_fs_read: no host filesystem provided") - return []uint64{0} - } - - data, err := fs.Read(path, offset, size) - if err != nil { - log.Errorf("host_fs_read: error reading file: %v", err) - return []uint64{0} - } - - // Write data to WASM memory - dataPtr, _, err := writeBytesToMemory(mod, data) - if err != nil { - log.Errorf("host_fs_read: failed to write data to memory: %v", err) - return []uint64{0} - } - - // Pack pointer and size into single u64 - // Lower 32 bits = pointer, upper 32 bits = size - packed := uint64(dataPtr) | (uint64(len(data)) << 32) - return []uint64{packed} -} - -func HostFSWrite(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - dataPtr := uint32(params[1]) - dataLen := uint32(params[2]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - log.Errorf("host_fs_write: failed to read path from memory") - return []uint64{0} - } - - data, ok := mod.Memory().Read(dataPtr, dataLen) - if !ok { - log.Errorf("host_fs_write: failed to read data from memory") - return []uint64{0} - } - - log.Debugf("host_fs_write: path=%s, dataLen=%d", path, dataLen) - - if fs == nil { - log.Errorf("host_fs_write: no host filesystem provided") - return []uint64{0} - } - - // Note: WASM API doesn't support offset/flags yet, use default behavior - bytesWritten, err := fs.Write(path, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - log.Errorf("host_fs_write: error writing file: %v", err) - return []uint64{0} - } - - // Return bytes written as uint64 - return []uint64{uint64(bytesWritten)} -} - -func HostFSStat(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - log.Errorf("host_fs_stat: failed to read path from memory") - return []uint64{0} - } - - log.Debugf("host_fs_stat: path=%s", path) - - if fs == nil { - log.Errorf("host_fs_stat: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr) << 32} - } - - fileInfo, err := fs.Stat(path) - if err != nil { - log.Errorf("host_fs_stat: error stating file: %v", err) - // Pack error: upper 32 bits = error pointer - errStr := err.Error() - errPtr, _, err := writeStringToMemory(mod, errStr) - if err != nil { - return []uint64{0} - } - return []uint64{uint64(errPtr) << 32} - } - - // Serialize fileInfo to JSON - jsonData, err := json.Marshal(fileInfo) - if err != nil { - log.Errorf("host_fs_stat: failed to marshal fileInfo: %v", err) - return []uint64{0} - } - - jsonPtr, _, err := writeStringToMemory(mod, string(jsonData)) - if err != nil { - log.Errorf("host_fs_stat: failed to write JSON to memory: %v", err) - return []uint64{0} - } - - // Pack: lower 32 bits = json pointer, upper 32 bits = 0 (no error) - return []uint64{uint64(jsonPtr)} -} - -func HostFSReadDir(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - log.Errorf("host_fs_readdir: failed to read path from memory") - return []uint64{0} - } - - log.Debugf("host_fs_readdir: path=%s", path) - - if fs == nil { - log.Errorf("host_fs_readdir: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr) << 32} - } - - fileInfos, err := fs.ReadDir(path) - if err != nil { - log.Errorf("host_fs_readdir: error reading directory: %v", err) - errStr := err.Error() - errPtr, _, err := writeStringToMemory(mod, errStr) - if err != nil { - return []uint64{0} - } - return []uint64{uint64(errPtr) << 32} - } - - // Serialize fileInfos to JSON - jsonData, err := json.Marshal(fileInfos) - if err != nil { - log.Errorf("host_fs_readdir: failed to marshal fileInfos: %v", err) - return []uint64{0} - } - - jsonPtr, _, err := writeStringToMemory(mod, string(jsonData)) - if err != nil { - log.Errorf("host_fs_readdir: failed to write JSON to memory: %v", err) - return []uint64{0} - } - - return []uint64{uint64(jsonPtr)} -} - -func HostFSCreate(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - return []uint64{1} // Error - } - - log.Debugf("host_fs_create: path=%s", path) - - if fs == nil { - log.Errorf("host_fs_create: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr)} - } - - err := fs.Create(path) - if err != nil { - log.Errorf("host_fs_create: error creating file: %v", err) - errPtr, _, _ := writeStringToMemory(mod, err.Error()) - return []uint64{uint64(errPtr)} - } - - return []uint64{0} // Success -} - -func HostFSMkdir(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - perm := uint32(params[1]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - return []uint64{1} - } - - log.Debugf("host_fs_mkdir: path=%s, perm=%o", path, perm) - - if fs == nil { - log.Errorf("host_fs_mkdir: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr)} - } - - err := fs.Mkdir(path, perm) - if err != nil { - log.Errorf("host_fs_mkdir: error creating directory: %v", err) - errPtr, _, _ := writeStringToMemory(mod, err.Error()) - return []uint64{uint64(errPtr)} - } - - return []uint64{0} -} - -func HostFSRemove(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - return []uint64{1} - } - - log.Debugf("host_fs_remove: path=%s", path) - - if fs == nil { - log.Errorf("host_fs_remove: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr)} - } - - err := fs.Remove(path) - if err != nil { - log.Errorf("host_fs_remove: error removing: %v", err) - errPtr, _, _ := writeStringToMemory(mod, err.Error()) - return []uint64{uint64(errPtr)} - } - - return []uint64{0} -} - -func HostFSRemoveAll(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - return []uint64{1} - } - - log.Debugf("host_fs_remove_all: path=%s", path) - - if fs == nil { - log.Errorf("host_fs_remove_all: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr)} - } - - err := fs.RemoveAll(path) - if err != nil { - log.Errorf("host_fs_remove_all: error removing: %v", err) - errPtr, _, _ := writeStringToMemory(mod, err.Error()) - return []uint64{uint64(errPtr)} - } - - return []uint64{0} -} - -func HostFSRename(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - oldPathPtr := uint32(params[0]) - newPathPtr := uint32(params[1]) - - oldPath, ok := readStringFromMemory(mod, oldPathPtr) - if !ok { - return []uint64{1} - } - - newPath, ok := readStringFromMemory(mod, newPathPtr) - if !ok { - return []uint64{1} - } - - log.Debugf("host_fs_rename: oldPath=%s, newPath=%s", oldPath, newPath) - - if fs == nil { - log.Errorf("host_fs_rename: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr)} - } - - err := fs.Rename(oldPath, newPath) - if err != nil { - log.Errorf("host_fs_rename: error renaming: %v", err) - errPtr, _, _ := writeStringToMemory(mod, err.Error()) - return []uint64{uint64(errPtr)} - } - - return []uint64{0} -} - -func HostFSChmod(ctx context.Context, mod wazeroapi.Module, params []uint64, fs filesystem.FileSystem) []uint64 { - pathPtr := uint32(params[0]) - mode := uint32(params[1]) - - path, ok := readStringFromMemory(mod, pathPtr) - if !ok { - return []uint64{1} - } - - log.Debugf("host_fs_chmod: path=%s, mode=%o", path, mode) - - if fs == nil { - log.Errorf("host_fs_chmod: no host filesystem provided") - errPtr, _, _ := writeStringToMemory(mod, "no host filesystem provided") - return []uint64{uint64(errPtr)} - } - - err := fs.Chmod(path, mode) - if err != nil { - log.Errorf("host_fs_chmod: error changing mode: %v", err) - errPtr, _, _ := writeStringToMemory(mod, err.Error()) - return []uint64{uint64(errPtr)} - } - - return []uint64{0} -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/api/host_http.go b/third_party/agfs/agfs-server/pkg/plugin/api/host_http.go deleted file mode 100644 index 7467658bd..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/api/host_http.go +++ /dev/null @@ -1,151 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "io" - "net/http" - "strings" - "time" - - log "github.com/sirupsen/logrus" - wazeroapi "github.com/tetratelabs/wazero/api" -) - -// HTTPRequest represents an HTTP request from WASM -type HTTPRequest struct { - Method string `json:"method"` - URL string `json:"url"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` - Timeout int `json:"timeout"` // timeout in seconds -} - -// HTTPResponse represents an HTTP response to WASM -type HTTPResponse struct { - StatusCode int `json:"status_code"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body"` - Error string `json:"error,omitempty"` -} - -// HostHTTPRequest performs an HTTP request from the host -// Parameters: -// - params[0]: pointer to JSON-encoded HTTPRequest -// -// Returns: packed u64 (lower 32 bits = response pointer, upper 32 bits = response size) -func HostHTTPRequest(ctx context.Context, mod wazeroapi.Module, params []uint64) []uint64 { - requestPtr := uint32(params[0]) - - // Read request JSON from memory - requestJSON, ok := readStringFromMemory(mod, requestPtr) - if !ok { - log.Errorf("host_http_request: failed to read request from memory") - return []uint64{0} - } - - log.Debugf("host_http_request: requestJSON=%s", requestJSON) - - // Parse request - var req HTTPRequest - if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { - log.Errorf("host_http_request: failed to parse request JSON: %v", err) - resp := HTTPResponse{ - Error: "failed to parse request: " + err.Error(), - } - return packHTTPResponse(mod, &resp) - } - - // Validate method - if req.Method == "" { - req.Method = "GET" - } - - // Create HTTP client with timeout - timeout := time.Duration(req.Timeout) * time.Second - if timeout == 0 { - timeout = 30 * time.Second // default 30s timeout - } - client := &http.Client{ - Timeout: timeout, - } - - // Create HTTP request - var bodyReader io.Reader - if len(req.Body) > 0 { - bodyReader = strings.NewReader(string(req.Body)) - } - - httpReq, err := http.NewRequestWithContext(ctx, req.Method, req.URL, bodyReader) - if err != nil { - log.Errorf("host_http_request: failed to create request: %v", err) - resp := HTTPResponse{ - Error: "failed to create request: " + err.Error(), - } - return packHTTPResponse(mod, &resp) - } - - // Set headers - for key, value := range req.Headers { - httpReq.Header.Set(key, value) - } - - // Perform request - httpResp, err := client.Do(httpReq) - if err != nil { - log.Errorf("host_http_request: request failed: %v", err) - resp := HTTPResponse{ - Error: "request failed: " + err.Error(), - } - return packHTTPResponse(mod, &resp) - } - defer httpResp.Body.Close() - - // Read response body - respBody, err := io.ReadAll(httpResp.Body) - if err != nil { - log.Errorf("host_http_request: failed to read response body: %v", err) - resp := HTTPResponse{ - StatusCode: httpResp.StatusCode, - Error: "failed to read response body: " + err.Error(), - } - return packHTTPResponse(mod, &resp) - } - - // Build response headers map - respHeaders := make(map[string]string) - for key, values := range httpResp.Header { - if len(values) > 0 { - respHeaders[key] = values[0] // Take first value - } - } - - // Create response - resp := HTTPResponse{ - StatusCode: httpResp.StatusCode, - Headers: respHeaders, - Body: respBody, - } - - log.Debugf("host_http_request: status=%d, bodyLen=%d", resp.StatusCode, len(resp.Body)) - return packHTTPResponse(mod, &resp) -} - -// packHTTPResponse serializes and writes HTTPResponse to WASM memory -func packHTTPResponse(mod wazeroapi.Module, resp *HTTPResponse) []uint64 { - respJSON, err := json.Marshal(resp) - if err != nil { - log.Errorf("packHTTPResponse: failed to marshal response: %v", err) - return []uint64{0} - } - - respPtr, _, err := writeBytesToMemory(mod, respJSON) - if err != nil { - log.Errorf("packHTTPResponse: failed to write response to memory: %v", err) - return []uint64{0} - } - - // Pack pointer and size - packed := uint64(respPtr) | (uint64(len(respJSON)) << 32) - return []uint64{packed} -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/api/plugin_api.go b/third_party/agfs/agfs-server/pkg/plugin/api/plugin_api.go deleted file mode 100644 index bf8e35d8d..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/api/plugin_api.go +++ /dev/null @@ -1,108 +0,0 @@ -package api - -import ( - "fmt" - "unsafe" -) - -// ExternalPlugin represents a dynamically loaded plugin from a shared library -// This bridges the C-compatible API with Go's ServicePlugin interface -type ExternalPlugin struct { - libHandle uintptr - pluginPtr unsafe.Pointer - name string - vtable *PluginVTable - fileSystem *ExternalFileSystem -} - -// PluginVTable contains function pointers to the plugin's C-compatible API -type PluginVTable struct { - // Plugin lifecycle functions - PluginNew func() unsafe.Pointer - PluginFree func(unsafe.Pointer) - PluginName func(unsafe.Pointer) *byte - PluginValidate func(unsafe.Pointer, *byte) *byte // Returns error string or nil - PluginInitialize func(unsafe.Pointer, *byte) *byte // Returns error string or nil - PluginShutdown func(unsafe.Pointer) *byte // Returns error string or nil - PluginGetReadme func(unsafe.Pointer) *byte - - // FileSystem operation functions - FSCreate func(unsafe.Pointer, *byte) *byte - FSMkdir func(unsafe.Pointer, *byte, uint32) *byte - FSRemove func(unsafe.Pointer, *byte) *byte - FSRemoveAll func(unsafe.Pointer, *byte) *byte - FSRead func(unsafe.Pointer, *byte, int64, int64, *int) *byte // Returns data, sets size - FSWrite func(unsafe.Pointer, *byte, *byte, int, int64, uint32) int64 // NEW: (plugin, path, data, len, offset, flags) -> bytes_written (-1 = error) - FSReadDir func(unsafe.Pointer, *byte, *int) *FileInfoArray // Returns array, sets count - FSStat func(unsafe.Pointer, *byte) *FileInfoC - FSRename func(unsafe.Pointer, *byte, *byte) *byte - FSChmod func(unsafe.Pointer, *byte, uint32) *byte -} - -// FileInfoC is the C-compatible representation of filesystem.FileInfo -type FileInfoC struct { - Name *byte // C string - Size int64 - Mode uint32 - ModTime int64 // Unix timestamp - IsDir int32 // Boolean as int - // Metadata fields - MetaName *byte - MetaType *byte - MetaContent *byte // JSON-encoded map[string]string -} - -// FileInfoArray is used for returning multiple FileInfo from C -type FileInfoArray struct { - Items *FileInfoC - Count int -} - -// ExternalFileSystem implements filesystem.FileSystem by delegating to C functions -type ExternalFileSystem struct { - pluginPtr unsafe.Pointer - vtable *PluginVTable -} - -// Helper functions to convert between Go and C types - -// CString converts a Go string to a C string (caller must free) -func CString(s string) *byte { - if s == "" { - return nil - } - b := append([]byte(s), 0) - return &b[0] -} - -// GoString converts a C string to a Go string -func GoString(cstr *byte) string { - if cstr == nil { - return "" - } - var length int - for { - ptr := unsafe.Pointer(uintptr(unsafe.Pointer(cstr)) + uintptr(length)) - if *(*byte)(ptr) == 0 { - break - } - length++ - } - if length == 0 { - return "" - } - return string(unsafe.Slice(cstr, length)) -} - -// GoError converts a C error string to a Go error, or nil if no error -func GoError(errStr *byte) error { - if errStr == nil { - return nil - } - msg := GoString(errStr) - if msg == "" { - return nil - } - // Return a simple error with the message - return fmt.Errorf("%s", msg) -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/api/wasm_instance_pool.go b/third_party/agfs/agfs-server/pkg/plugin/api/wasm_instance_pool.go deleted file mode 100644 index a8f759edc..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/api/wasm_instance_pool.go +++ /dev/null @@ -1,467 +0,0 @@ -package api - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - log "github.com/sirupsen/logrus" - "github.com/tetratelabs/wazero" - wazeroapi "github.com/tetratelabs/wazero/api" -) - -// PoolConfig contains configuration for the instance pool -type PoolConfig struct { - MaxInstances int // Maximum number of concurrent instances - InstanceMaxLifetime time.Duration // Maximum instance lifetime (0 = unlimited) - InstanceMaxRequests int64 // Maximum requests per instance (0 = unlimited) - HealthCheckInterval time.Duration // Health check interval (0 = disabled) - AcquireTimeout time.Duration // Timeout for acquiring instance (0 = unlimited, default 30s) - EnableStatistics bool // Enable statistics collection -} - -// WASMInstancePool manages a pool of WASM module instances for concurrent access -type WASMInstancePool struct { - ctx context.Context - runtime wazero.Runtime - compiledModule wazero.CompiledModule - hostFS filesystem.FileSystem - pluginName string - config PoolConfig - instances chan *WASMModuleInstance - currentInstances int - mu sync.Mutex - stats PoolStats - closed bool -} - -// PoolStats tracks pool usage statistics -type PoolStats struct { - TotalCreated int64 - TotalDestroyed int64 - CurrentActive int64 - TotalWaits int64 - TotalRequests int64 - FailedRequests int64 - mu sync.Mutex -} - -// SharedBufferInfo holds information about shared memory buffers -type SharedBufferInfo struct { - InputBufferPtr uint32 // Pointer to input buffer (Go -> WASM) - OutputBufferPtr uint32 // Pointer to output buffer (WASM -> Go) - BufferSize uint32 // Size of each buffer - Enabled bool // Whether shared buffers are available -} - -// WASMModuleInstance represents a single WASM module instance -type WASMModuleInstance struct { - module wazeroapi.Module - fileSystem *WASMFileSystem - sharedBuffer SharedBufferInfo - createdAt time.Time - requestCount int64 // Number of requests handled by this instance - mu sync.Mutex -} - -// NewWASMInstancePool creates a new WASM instance pool with configuration -func NewWASMInstancePool(ctx context.Context, runtime wazero.Runtime, compiledModule wazero.CompiledModule, - pluginName string, config PoolConfig, hostFS filesystem.FileSystem) *WASMInstancePool { - - // Apply defaults - if config.MaxInstances <= 0 { - config.MaxInstances = 10 // default to 10 concurrent instances - } - if config.AcquireTimeout == 0 { - config.AcquireTimeout = 30 * time.Second // default to 30 second timeout - } - - pool := &WASMInstancePool{ - ctx: ctx, - runtime: runtime, - compiledModule: compiledModule, - hostFS: hostFS, - pluginName: pluginName, - config: config, - instances: make(chan *WASMModuleInstance, config.MaxInstances), - } - - log.Infof("Created WASM instance pool for %s (max_instances=%d, max_lifetime=%v, max_requests=%d)", - pluginName, config.MaxInstances, config.InstanceMaxLifetime, config.InstanceMaxRequests) - - // Start health check goroutine if enabled - if config.HealthCheckInterval > 0 { - go pool.healthCheckLoop() - } - - return pool -} - -// healthCheckLoop periodically checks instance health -func (p *WASMInstancePool) healthCheckLoop() { - ticker := time.NewTicker(p.config.HealthCheckInterval) - defer ticker.Stop() - - for { - select { - case <-p.ctx.Done(): - return - case <-ticker.C: - p.performHealthCheck() - } - } -} - -// performHealthCheck checks the health of instances in the pool -func (p *WASMInstancePool) performHealthCheck() { - p.mu.Lock() - closed := p.closed - p.mu.Unlock() - - if closed { - return - } - - log.Debugf("[Pool %s] Health check: active instances=%d/%d", - p.pluginName, p.currentInstances, p.config.MaxInstances) -} - -// Acquire gets an instance from the pool or creates a new one if available -func (p *WASMInstancePool) Acquire() (*WASMModuleInstance, error) { - // Check if pool is closed - p.mu.Lock() - if p.closed { - p.mu.Unlock() - return nil, fmt.Errorf("instance pool is closed") - } - p.mu.Unlock() - - // Increment request counter if statistics enabled - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.TotalRequests++ - p.stats.mu.Unlock() - } - - // Try to get an existing instance from the pool - select { - case instance := <-p.instances: - // Check if instance needs to be recycled - if p.shouldRecycleInstance(instance) { - log.Debugf("Recycling expired WASM instance for %s", p.pluginName) - p.destroyInstance(instance) - - p.mu.Lock() - p.currentInstances-- - p.mu.Unlock() - - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.TotalDestroyed++ - p.stats.CurrentActive-- - p.stats.mu.Unlock() - } - - // Create a new instance to replace the recycled one - return p.Acquire() - } - - log.Debugf("Reusing WASM instance from pool for %s", p.pluginName) - - // Increment request count for this instance - instance.mu.Lock() - instance.requestCount++ - instance.mu.Unlock() - - return instance, nil - default: - // No available instance, try to create a new one - p.mu.Lock() - canCreate := p.currentInstances < p.config.MaxInstances - if canCreate { - p.currentInstances++ - } - p.mu.Unlock() - - if canCreate { - instance, err := p.createInstance() - if err != nil { - p.mu.Lock() - p.currentInstances-- - p.mu.Unlock() - - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.FailedRequests++ - p.stats.mu.Unlock() - } - return nil, err - } - - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.TotalCreated++ - p.stats.CurrentActive++ - p.stats.mu.Unlock() - } - - log.Debugf("Created new WASM instance for %s (total: %d/%d)", - p.pluginName, p.currentInstances, p.config.MaxInstances) - - // Increment request count for this instance - instance.mu.Lock() - instance.requestCount++ - instance.mu.Unlock() - - return instance, nil - } - - // Pool is full, wait for an available instance - log.Debugf("WASM pool full for %s, waiting for available instance...", p.pluginName) - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.TotalWaits++ - p.stats.mu.Unlock() - } - - // Wait with timeout to prevent deadlock - var instance *WASMModuleInstance - select { - case instance = <-p.instances: - // Got an instance - case <-time.After(p.config.AcquireTimeout): - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.FailedRequests++ - p.stats.mu.Unlock() - } - return nil, fmt.Errorf("timeout waiting for available WASM instance after %v", p.config.AcquireTimeout) - } - - // Check if instance needs to be recycled - if p.shouldRecycleInstance(instance) { - log.Debugf("Recycling expired WASM instance for %s", p.pluginName) - p.destroyInstance(instance) - - p.mu.Lock() - p.currentInstances-- - p.mu.Unlock() - - if p.config.EnableStatistics { - p.stats.mu.Lock() - p.stats.TotalDestroyed++ - p.stats.CurrentActive-- - p.stats.mu.Unlock() - } - - // Create a new instance to replace the recycled one - return p.Acquire() - } - - // Increment request count for this instance - instance.mu.Lock() - instance.requestCount++ - instance.mu.Unlock() - - return instance, nil - } -} - -// shouldRecycleInstance checks if an instance should be recycled -func (p *WASMInstancePool) shouldRecycleInstance(instance *WASMModuleInstance) bool { - instance.mu.Lock() - defer instance.mu.Unlock() - - // Check max lifetime - if p.config.InstanceMaxLifetime > 0 { - age := time.Since(instance.createdAt) - if age > p.config.InstanceMaxLifetime { - log.Debugf("Instance exceeded max lifetime: %v > %v", age, p.config.InstanceMaxLifetime) - return true - } - } - - // Check max requests - if p.config.InstanceMaxRequests > 0 && instance.requestCount >= p.config.InstanceMaxRequests { - log.Debugf("Instance exceeded max requests: %d >= %d", instance.requestCount, p.config.InstanceMaxRequests) - return true - } - - return false -} - -// Release returns an instance to the pool -func (p *WASMInstancePool) Release(instance *WASMModuleInstance) { - if instance == nil { - return - } - - // Try to return to pool, if pool is full, destroy the instance - select { - case p.instances <- instance: - log.Debugf("Returned WASM instance to pool for %s", p.pluginName) - default: - // Pool is full, destroy this instance - log.Debugf("Pool full, destroying excess WASM instance for %s", p.pluginName) - p.destroyInstance(instance) - - p.mu.Lock() - p.currentInstances-- - p.mu.Unlock() - - p.stats.mu.Lock() - p.stats.TotalDestroyed++ - p.stats.CurrentActive-- - p.stats.mu.Unlock() - } -} - -// createInstance creates a new WASM module instance -func (p *WASMInstancePool) createInstance() (*WASMModuleInstance, error) { - // Instantiate the compiled module - module, err := p.runtime.InstantiateModule(p.ctx, p.compiledModule, wazero.NewModuleConfig()) - if err != nil { - return nil, fmt.Errorf("failed to instantiate WASM module: %w", err) - } - - // Call plugin_new to initialize - if newFunc := module.ExportedFunction("plugin_new"); newFunc != nil { - if _, err := newFunc.Call(p.ctx); err != nil { - module.Close(p.ctx) - return nil, fmt.Errorf("failed to call plugin_new: %w", err) - } - } - - // Initialize shared buffer info - sharedBuffer := initializeSharedBuffer(module, p.ctx) - - instance := &WASMModuleInstance{ - module: module, - createdAt: time.Now(), - sharedBuffer: sharedBuffer, - fileSystem: &WASMFileSystem{ - ctx: p.ctx, - module: module, - sharedBuffer: &sharedBuffer, - mu: nil, // No mutex needed - each instance is single-threaded - }, - } - - if sharedBuffer.Enabled { - log.Debugf("Shared buffers enabled for %s: input=%d, output=%d, size=%d", - p.pluginName, sharedBuffer.InputBufferPtr, sharedBuffer.OutputBufferPtr, sharedBuffer.BufferSize) - } - - return instance, nil -} - -// initializeSharedBuffer detects and initializes shared memory buffers -func initializeSharedBuffer(module wazeroapi.Module, ctx context.Context) SharedBufferInfo { - info := SharedBufferInfo{Enabled: false} - - // Try to get shared buffer functions - getInputBufFunc := module.ExportedFunction("get_input_buffer_ptr") - getOutputBufFunc := module.ExportedFunction("get_output_buffer_ptr") - getBufSizeFunc := module.ExportedFunction("get_shared_buffer_size") - - // All three functions must be available - if getInputBufFunc == nil || getOutputBufFunc == nil || getBufSizeFunc == nil { - log.Debug("Shared buffers not available (functions not exported)") - return info - } - - // Get buffer pointers and size - inputResults, err := getInputBufFunc.Call(ctx) - if err != nil || len(inputResults) == 0 { - log.Warnf("Failed to get input buffer pointer: %v", err) - return info - } - - outputResults, err := getOutputBufFunc.Call(ctx) - if err != nil || len(outputResults) == 0 { - log.Warnf("Failed to get output buffer pointer: %v", err) - return info - } - - sizeResults, err := getBufSizeFunc.Call(ctx) - if err != nil || len(sizeResults) == 0 { - log.Warnf("Failed to get buffer size: %v", err) - return info - } - - info.InputBufferPtr = uint32(inputResults[0]) - info.OutputBufferPtr = uint32(outputResults[0]) - info.BufferSize = uint32(sizeResults[0]) - info.Enabled = true - - return info -} - -// destroyInstance destroys a WASM module instance -func (p *WASMInstancePool) destroyInstance(instance *WASMModuleInstance) { - if instance == nil || instance.module == nil { - return - } - - // Call plugin shutdown if available - if shutdownFunc := instance.module.ExportedFunction("plugin_shutdown"); shutdownFunc != nil { - shutdownFunc.Call(p.ctx) - } - - // Close the module - instance.module.Close(p.ctx) -} - -// Close closes the pool and destroys all instances -func (p *WASMInstancePool) Close() error { - p.mu.Lock() - - // Mark as closed first to prevent new acquisitions - if p.closed { - p.mu.Unlock() - return nil - } - p.closed = true - p.mu.Unlock() - - // Close all instances in the pool - close(p.instances) - for instance := range p.instances { - p.destroyInstance(instance) - } - - log.Infof("Closed WASM instance pool for %s", p.pluginName) - return nil -} - -// GetStats returns the current pool statistics -func (p *WASMInstancePool) GetStats() PoolStats { - p.stats.mu.Lock() - defer p.stats.mu.Unlock() - return p.stats -} - -// Execute executes a function with an instance from the pool -// This is a convenience method that handles acquire/release automatically -func (p *WASMInstancePool) Execute(fn func(*WASMModuleInstance) error) error { - instance, err := p.Acquire() - if err != nil { - return err - } - defer p.Release(instance) - - return fn(instance) -} - -// ExecuteFS executes a filesystem operation with an instance from the pool -func (p *WASMInstancePool) ExecuteFS(fn func(filesystem.FileSystem) error) error { - instance, err := p.Acquire() - if err != nil { - return err - } - defer p.Release(instance) - - return fn(instance.fileSystem) -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/api/wasm_plugin.go b/third_party/agfs/agfs-server/pkg/plugin/api/wasm_plugin.go deleted file mode 100644 index 9abff0e72..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/api/wasm_plugin.go +++ /dev/null @@ -1,1611 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "io" - "sync" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - log "github.com/sirupsen/logrus" - wazeroapi "github.com/tetratelabs/wazero/api" -) - -// WASMPlugin represents a plugin loaded from a WASM module -// It uses an instance pool for concurrent access -type WASMPlugin struct { - name string - instancePool *WASMInstancePool - fileSystem *PooledWASMFileSystem -} - -// PooledWASMFileSystem implements filesystem.FileSystem using an instance pool -type PooledWASMFileSystem struct { - pool *WASMInstancePool - - // Handle management: maps handle ID to the handle object - // This ensures handle operations use the same handle instance with its bound WASM instance - handles map[int64]*PooledWASMFileHandle - handleMu sync.RWMutex - nextHandleID int64 -} - -// WASMFileSystem implements filesystem.FileSystem by delegating to WASM functions -// This version is used for individual instances within the pool -type WASMFileSystem struct { - ctx context.Context - module wazeroapi.Module - sharedBuffer *SharedBufferInfo // Shared memory buffer info (can be nil) - mu *sync.Mutex // Mutex for single instance (can be nil if instance is not shared) -} - -// NewWASMPluginWithPool creates a new WASM plugin wrapper with an instance pool -func NewWASMPluginWithPool(pool *WASMInstancePool, name string) (*WASMPlugin, error) { - if pool == nil { - return nil, fmt.Errorf("instance pool cannot be nil") - } - - wp := &WASMPlugin{ - name: name, - instancePool: pool, - fileSystem: &PooledWASMFileSystem{ - pool: pool, - handles: make(map[int64]*PooledWASMFileHandle), - nextHandleID: 1, - }, - } - - return wp, nil -} - -// NewWASMPlugin creates a new WASM plugin wrapper (legacy, for backward compatibility) -// For new code, use NewWASMPluginWithPool for better concurrency -func NewWASMPlugin(ctx context.Context, module wazeroapi.Module) (*WASMPlugin, error) { - // This is kept for backward compatibility but should be migrated to pool-based approach - // For now, return an error suggesting to use the pool-based approach - return nil, fmt.Errorf("NewWASMPlugin is deprecated, use NewWASMPluginWithPool for concurrent access") -} - -// Name returns the plugin name -func (wp *WASMPlugin) Name() string { - return wp.name -} - -// Validate validates the plugin configuration -func (wp *WASMPlugin) Validate(config map[string]interface{}) error { - return wp.instancePool.Execute(func(instance *WASMModuleInstance) error { - validateFunc := instance.module.ExportedFunction("plugin_validate") - if validateFunc == nil { - // If validate function is not exported, assume validation passes - return nil - } - - // Convert config to JSON - configJSON, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - // Write config to WASM memory - configPtr, configPtrSize, err := writeStringToMemory(instance.module, string(configJSON)) - if err != nil { - return fmt.Errorf("failed to write config to memory: %w", err) - } - defer freeWASMMemory(instance.module, configPtr, configPtrSize) - - // Call validate function - results, err := validateFunc.Call(wp.instancePool.ctx, uint64(configPtr)) - if err != nil { - return fmt.Errorf("validate call failed: %w", err) - } - - // Check for error return (non-zero means error) - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(instance.module, errPtr); ok { - freeWASMMemory(instance.module, errPtr, 0) - return fmt.Errorf("validation failed: %s", errMsg) - } - freeWASMMemory(instance.module, errPtr, 0) - return fmt.Errorf("validation failed") - } - - return nil - }) -} - -// Initialize initializes the plugin with configuration -func (wp *WASMPlugin) Initialize(config map[string]interface{}) error { - return wp.instancePool.Execute(func(instance *WASMModuleInstance) error { - initFunc := instance.module.ExportedFunction("plugin_initialize") - if initFunc == nil { - // If initialize function is not exported, assume initialization succeeds - return nil - } - - // Convert config to JSON - configJSON, err := json.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - // Write config to WASM memory - configPtr, configPtrSize, err := writeStringToMemory(instance.module, string(configJSON)) - if err != nil { - return fmt.Errorf("failed to write config to memory: %w", err) - } - defer freeWASMMemory(instance.module, configPtr, configPtrSize) - - // Call initialize function - results, err := initFunc.Call(wp.instancePool.ctx, uint64(configPtr)) - if err != nil { - return fmt.Errorf("initialize call failed: %w", err) - } - - // Check for error return - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(instance.module, errPtr); ok { - freeWASMMemory(instance.module, errPtr, 0) - return fmt.Errorf("initialization failed: %s", errMsg) - } - freeWASMMemory(instance.module, errPtr, 0) - return fmt.Errorf("initialization failed") - } - - return nil - }) -} - -// GetFileSystem returns the file system implementation -func (wp *WASMPlugin) GetFileSystem() filesystem.FileSystem { - return wp.fileSystem -} - -// GetReadme returns the plugin README -func (wp *WASMPlugin) GetReadme() string { - var readme string - wp.instancePool.Execute(func(instance *WASMModuleInstance) error { - readmeFunc := instance.module.ExportedFunction("plugin_get_readme") - if readmeFunc == nil { - readme = "" - return nil - } - - results, err := readmeFunc.Call(wp.instancePool.ctx) - if err != nil { - log.Warnf("Failed to get readme: %v", err) - readme = "" - return nil - } - - if len(results) > 0 && results[0] != 0 { - ptr := uint32(results[0]) - if r, ok := readStringFromMemory(instance.module, ptr); ok { - readme = r - } - freeWASMMemory(instance.module, ptr, 0) - } - - return nil - }) - - return readme -} - -// GetConfigParams returns the list of configuration parameters -func (wp *WASMPlugin) GetConfigParams() []plugin.ConfigParameter { - var params []plugin.ConfigParameter - wp.instancePool.Execute(func(instance *WASMModuleInstance) error { - // Check if the plugin exports plugin_get_config_params - configParamsFunc := instance.module.ExportedFunction("plugin_get_config_params") - if configParamsFunc == nil { - // Plugin doesn't export config params, return empty list - params = []plugin.ConfigParameter{} - return nil - } - - // Call the function to get config params JSON - results, err := configParamsFunc.Call(wp.instancePool.ctx) - if err != nil { - log.Warnf("Failed to get config params: %v", err) - params = []plugin.ConfigParameter{} - return nil - } - - if len(results) > 0 && results[0] != 0 { - ptr := uint32(results[0]) - // Read JSON string from WASM memory - if jsonStr, ok := readStringFromMemory(instance.module, ptr); ok { - // Parse JSON into ConfigParameter array - if err := json.Unmarshal([]byte(jsonStr), ¶ms); err != nil { - log.Warnf("Failed to unmarshal config params JSON: %v", err) - params = []plugin.ConfigParameter{} - } - } - freeWASMMemory(instance.module, ptr, 0) - } - - return nil - }) - - return params -} - -// Shutdown shuts down the plugin -func (wp *WASMPlugin) Shutdown() error { - // Close the instance pool - return wp.instancePool.Close() -} - -// PooledWASMFileSystem implementation -// All methods delegate to the instance pool - -func (pfs *PooledWASMFileSystem) Create(path string) error { - return pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - return fs.Create(path) - }) -} - -func (pfs *PooledWASMFileSystem) Mkdir(path string, perm uint32) error { - return pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - return fs.Mkdir(path, perm) - }) -} - -func (pfs *PooledWASMFileSystem) Remove(path string) error { - return pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - return fs.Remove(path) - }) -} - -func (pfs *PooledWASMFileSystem) RemoveAll(path string) error { - return pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - return fs.RemoveAll(path) - }) -} - -func (pfs *PooledWASMFileSystem) Read(path string, offset int64, size int64) ([]byte, error) { - var data []byte - err := pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - var readErr error - data, readErr = fs.Read(path, offset, size) - return readErr - }) - return data, err -} - -func (pfs *PooledWASMFileSystem) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - var bytesWritten int64 - err := pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - var writeErr error - bytesWritten, writeErr = fs.Write(path, data, offset, flags) - return writeErr - }) - return bytesWritten, err -} - -func (pfs *PooledWASMFileSystem) ReadDir(path string) ([]filesystem.FileInfo, error) { - var infos []filesystem.FileInfo - err := pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - var readErr error - infos, readErr = fs.ReadDir(path) - return readErr - }) - return infos, err -} - -func (pfs *PooledWASMFileSystem) Stat(path string) (*filesystem.FileInfo, error) { - var info *filesystem.FileInfo - err := pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - var statErr error - info, statErr = fs.Stat(path) - return statErr - }) - return info, err -} - -func (pfs *PooledWASMFileSystem) Rename(oldPath, newPath string) error { - return pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - return fs.Rename(oldPath, newPath) - }) -} - -func (pfs *PooledWASMFileSystem) Chmod(path string, mode uint32) error { - return pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - return fs.Chmod(path, mode) - }) -} - -func (pfs *PooledWASMFileSystem) Open(path string) (io.ReadCloser, error) { - var reader io.ReadCloser - err := pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - var openErr error - reader, openErr = fs.Open(path) - return openErr - }) - return reader, err -} - -func (pfs *PooledWASMFileSystem) OpenWrite(path string) (io.WriteCloser, error) { - var writer io.WriteCloser - err := pfs.pool.ExecuteFS(func(fs filesystem.FileSystem) error { - var openErr error - writer, openErr = fs.OpenWrite(path) - return openErr - }) - return writer, err -} - -// HandleFS interface for PooledWASMFileSystem - -// SupportsHandleFS checks if the underlying WASM plugin supports HandleFS -func (pfs *PooledWASMFileSystem) SupportsHandleFS() bool { - var supports bool - pfs.pool.Execute(func(instance *WASMModuleInstance) error { - openFunc := instance.module.ExportedFunction("handle_open") - supports = openFunc != nil - return nil - }) - return supports -} - -// OpenHandle opens a file and returns a handle -// This acquires a WASM instance and keeps it bound to this handle until close -func (pfs *PooledWASMFileSystem) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - // Acquire an instance from the pool (and don't release it until handle is closed) - instance, err := pfs.pool.Acquire() - if err != nil { - return nil, fmt.Errorf("failed to acquire WASM instance: %w", err) - } - - // Call OpenHandle on the WASM instance - handle, err := instance.fileSystem.OpenHandle(path, flags, mode) - if err != nil { - // Release the instance back to pool on error - pfs.pool.Release(instance) - return nil, err - } - - // Assign a new int64 handle ID - pfs.handleMu.Lock() - handleID := pfs.nextHandleID - pfs.nextHandleID++ - - // Create a wrapped handle that routes operations through our tracking - pooledHandle := &PooledWASMFileHandle{ - id: handleID, - inner: handle.(*WASMFileHandle), - pfs: pfs, - instance: instance, - } - - // Store the handle object so GetHandle can return it - pfs.handles[handleID] = pooledHandle - pfs.handleMu.Unlock() - - return pooledHandle, nil -} - -// GetHandle retrieves an existing handle by ID -// Returns the same handle object that was created by OpenHandle -func (pfs *PooledWASMFileSystem) GetHandle(id int64) (filesystem.FileHandle, error) { - pfs.handleMu.RLock() - handle, ok := pfs.handles[id] - pfs.handleMu.RUnlock() - - if !ok { - return nil, fmt.Errorf("handle not found: %d", id) - } - - if handle.closed { - return nil, fmt.Errorf("handle is closed: %d", id) - } - - return handle, nil -} - -// CloseHandle closes a handle by ID -func (pfs *PooledWASMFileSystem) CloseHandle(id int64) error { - pfs.handleMu.Lock() - handle, ok := pfs.handles[id] - if ok { - delete(pfs.handles, id) - } - pfs.handleMu.Unlock() - - if !ok { - return fmt.Errorf("handle not found: %d", id) - } - - return handle.Close() -} - -// PooledWASMFileHandle wraps WASMFileHandle to manage instance lifecycle -type PooledWASMFileHandle struct { - id int64 - inner *WASMFileHandle - pfs *PooledWASMFileSystem - instance *WASMModuleInstance - closed bool - mu sync.Mutex -} - -func (h *PooledWASMFileHandle) ID() int64 { - return h.id -} - -func (h *PooledWASMFileHandle) Path() string { - return h.inner.Path() -} - -func (h *PooledWASMFileHandle) Flags() filesystem.OpenFlag { - return h.inner.Flags() -} - -func (h *PooledWASMFileHandle) Read(buf []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.inner.Read(buf) -} - -func (h *PooledWASMFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.inner.ReadAt(buf, offset) -} - -func (h *PooledWASMFileHandle) Write(data []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.inner.Write(data) -} - -func (h *PooledWASMFileHandle) WriteAt(data []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.inner.WriteAt(data, offset) -} - -func (h *PooledWASMFileHandle) Seek(offset int64, whence int) (int64, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.inner.Seek(offset, whence) -} - -func (h *PooledWASMFileHandle) Sync() error { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return fmt.Errorf("handle is closed") - } - return h.inner.Sync() -} - -func (h *PooledWASMFileHandle) Stat() (*filesystem.FileInfo, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return nil, fmt.Errorf("handle is closed") - } - return h.inner.Stat() -} - -func (h *PooledWASMFileHandle) Close() error { - h.mu.Lock() - defer h.mu.Unlock() - if h.closed { - return fmt.Errorf("handle is already closed") - } - h.closed = true - - // Close the inner handle - err := h.inner.Close() - - // Remove from tracking (if not already removed by CloseHandle) - h.pfs.handleMu.Lock() - delete(h.pfs.handles, h.id) - h.pfs.handleMu.Unlock() - - // Release the WASM instance back to the pool - h.pfs.pool.Release(h.instance) - - return err -} - -// WASMFileSystem implementations - -func (wfs *WASMFileSystem) Create(path string) error { - createFunc := wfs.module.ExportedFunction("fs_create") - if createFunc == nil { - return fmt.Errorf("fs_create not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemoryWithBuffer(wfs.module, path, wfs.sharedBuffer) - if err != nil { - return err - } - defer freeWASMMemoryWithBuffer(wfs.module, pathPtr, pathPtrSize, wfs.sharedBuffer) - - results, err := createFunc.Call(wfs.ctx, uint64(pathPtr)) - if err != nil { - return fmt.Errorf("fs_create failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("create failed") - } - - return nil -} - -func (wfs *WASMFileSystem) Mkdir(path string, perm uint32) error { - mkdirFunc := wfs.module.ExportedFunction("fs_mkdir") - if mkdirFunc == nil { - return fmt.Errorf("fs_mkdir not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemory(wfs.module, path) - if err != nil { - return err - } - defer freeWASMMemory(wfs.module, pathPtr, pathPtrSize) - - results, err := mkdirFunc.Call(wfs.ctx, uint64(pathPtr), uint64(perm)) - if err != nil { - return fmt.Errorf("fs_mkdir failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("mkdir failed") - } - - return nil -} - -func (wfs *WASMFileSystem) Remove(path string) error { - removeFunc := wfs.module.ExportedFunction("fs_remove") - if removeFunc == nil { - return fmt.Errorf("fs_remove not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemory(wfs.module, path) - if err != nil { - return err - } - defer freeWASMMemory(wfs.module, pathPtr, pathPtrSize) - - results, err := removeFunc.Call(wfs.ctx, uint64(pathPtr)) - if err != nil { - return fmt.Errorf("fs_remove failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("remove failed") - } - - return nil -} - -func (wfs *WASMFileSystem) RemoveAll(path string) error { - removeAllFunc := wfs.module.ExportedFunction("fs_remove_all") - if removeAllFunc == nil { - // Fall back to Remove if RemoveAll not implemented - return wfs.Remove(path) - } - - pathPtr, pathPtrSize, err := writeStringToMemory(wfs.module, path) - if err != nil { - return err - } - defer freeWASMMemory(wfs.module, pathPtr, pathPtrSize) - - results, err := removeAllFunc.Call(wfs.ctx, uint64(pathPtr)) - if err != nil { - return fmt.Errorf("fs_remove_all failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("remove_all failed") - } - - return nil -} - -func (wfs *WASMFileSystem) Read(path string, offset int64, size int64) ([]byte, error) { - // Only lock if mutex is not nil (for backward compatibility) - // Pooled instances don't need mutex as they're single-threaded - if wfs.mu != nil { - wfs.mu.Lock() - defer wfs.mu.Unlock() - } - - readFunc := wfs.module.ExportedFunction("fs_read") - if readFunc == nil { - return nil, fmt.Errorf("fs_read not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemoryWithBuffer(wfs.module, path, wfs.sharedBuffer) - if err != nil { - return nil, err - } - defer freeWASMMemoryWithBuffer(wfs.module, pathPtr, pathPtrSize, wfs.sharedBuffer) - - results, err := readFunc.Call(wfs.ctx, uint64(pathPtr), uint64(offset), uint64(size)) - if err != nil { - return nil, fmt.Errorf("fs_read failed: %w", err) - } - - if len(results) < 1 { - return nil, fmt.Errorf("fs_read returned invalid results") - } - - // Unpack u64: lower 32 bits = pointer, upper 32 bits = size - packed := results[0] - dataPtr := uint32(packed & 0xFFFFFFFF) - dataSize := uint32((packed >> 32) & 0xFFFFFFFF) - - if dataPtr == 0 { - return nil, fmt.Errorf("read failed") - } - - data, ok := wfs.module.Memory().Read(dataPtr, dataSize) - if !ok { - freeWASMMemory(wfs.module, dataPtr, 0) - return nil, fmt.Errorf("failed to read data from memory") - } - - // Free WASM memory after copying data - freeWASMMemory(wfs.module, dataPtr, 0) - - return data, nil -} - -func (wfs *WASMFileSystem) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - writeFunc := wfs.module.ExportedFunction("fs_write") - if writeFunc == nil { - return 0, fmt.Errorf("fs_write not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemoryWithBuffer(wfs.module, path, wfs.sharedBuffer) - if err != nil { - return 0, err - } - defer freeWASMMemoryWithBuffer(wfs.module, pathPtr, pathPtrSize, wfs.sharedBuffer) - - dataPtr, dataPtrSize, err := writeBytesToMemoryWithBuffer(wfs.module, data, wfs.sharedBuffer) - if err != nil { - return 0, err - } - defer freeWASMMemoryWithBuffer(wfs.module, dataPtr, dataPtrSize, wfs.sharedBuffer) - - // Call WASM plugin with new signature: fs_write(path, data, len, offset, flags) -> packed u64 - results, err := writeFunc.Call(wfs.ctx, uint64(pathPtr), uint64(dataPtr), uint64(len(data)), uint64(offset), uint64(flags)) - if err != nil { - return 0, fmt.Errorf("fs_write failed: %w", err) - } - - if len(results) < 1 { - return 0, fmt.Errorf("fs_write returned invalid results") - } - - // New return format: packed u64 with high 32 bits = bytes written, low 32 bits = error ptr - packed := results[0] - bytesWritten := uint32(packed >> 32) - errPtr := uint32(packed & 0xFFFFFFFF) - - if errPtr != 0 { - // Read error message from WASM memory - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return 0, fmt.Errorf("write failed: %s", errMsg) - } - return 0, fmt.Errorf("write failed") - } - - return int64(bytesWritten), nil -} - -func (wfs *WASMFileSystem) ReadDir(path string) ([]filesystem.FileInfo, error) { - readDirFunc := wfs.module.ExportedFunction("fs_readdir") - if readDirFunc == nil { - return nil, fmt.Errorf("fs_readdir not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemory(wfs.module, path) - if err != nil { - return nil, err - } - defer freeWASMMemory(wfs.module, pathPtr, pathPtrSize) - - results, err := readDirFunc.Call(wfs.ctx, uint64(pathPtr)) - if err != nil { - return nil, fmt.Errorf("fs_readdir failed: %w", err) - } - - if len(results) < 1 { - return nil, fmt.Errorf("fs_readdir returned invalid results") - } - - // Unpack u64: lower 32 bits = json pointer, upper 32 bits = error pointer - packed := results[0] - jsonPtr := uint32(packed & 0xFFFFFFFF) - errPtr := uint32((packed >> 32) & 0xFFFFFFFF) - - // Check for error - if errPtr != 0 { - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return nil, fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return nil, fmt.Errorf("readdir failed") - } - - if jsonPtr == 0 { - return []filesystem.FileInfo{}, nil - } - - jsonStr, ok := readStringFromMemory(wfs.module, jsonPtr) - if !ok { - freeWASMMemory(wfs.module, jsonPtr, 0) - return nil, fmt.Errorf("failed to read readdir result") - } - - // Free WASM memory after reading - freeWASMMemory(wfs.module, jsonPtr, 0) - - var fileInfos []filesystem.FileInfo - if err := json.Unmarshal([]byte(jsonStr), &fileInfos); err != nil { - return nil, fmt.Errorf("failed to unmarshal readdir result: %w", err) - } - - return fileInfos, nil -} - -func (wfs *WASMFileSystem) Stat(path string) (*filesystem.FileInfo, error) { - log.Debugf("WASM Stat called with path: %s", path) - statFunc := wfs.module.ExportedFunction("fs_stat") - if statFunc == nil { - return nil, fmt.Errorf("fs_stat not implemented") - } - - pathPtr, pathPtrSize, err := writeStringToMemoryWithBuffer(wfs.module, path, wfs.sharedBuffer) - if err != nil { - log.Errorf("Failed to write path to memory: %v", err) - return nil, err - } - defer freeWASMMemoryWithBuffer(wfs.module, pathPtr, pathPtrSize, wfs.sharedBuffer) - - log.Debugf("Calling fs_stat WASM function with pathPtr=%d", pathPtr) - results, err := statFunc.Call(wfs.ctx, uint64(pathPtr)) - if err != nil { - log.Errorf("fs_stat WASM call failed: %v", err) - return nil, fmt.Errorf("fs_stat failed: %w", err) - } - log.Debugf("fs_stat returned %d results", len(results)) - - if len(results) < 1 { - return nil, fmt.Errorf("fs_stat returned invalid results") - } - - // Unpack u64: lower 32 bits = json pointer, upper 32 bits = error pointer - packed := results[0] - jsonPtr := uint32(packed & 0xFFFFFFFF) - errPtr := uint32((packed >> 32) & 0xFFFFFFFF) - - // Check for error - if errPtr != 0 { - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return nil, fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return nil, fmt.Errorf("stat failed") - } - - if jsonPtr == 0 { - return nil, fmt.Errorf("stat returned null") - } - - jsonStr, ok := readStringFromMemory(wfs.module, jsonPtr) - if !ok { - freeWASMMemory(wfs.module, jsonPtr, 0) - return nil, fmt.Errorf("failed to read stat result") - } - - // Free WASM memory after reading - freeWASMMemory(wfs.module, jsonPtr, 0) - - var fileInfo filesystem.FileInfo - if err := json.Unmarshal([]byte(jsonStr), &fileInfo); err != nil { - return nil, fmt.Errorf("failed to unmarshal stat result: %w", err) - } - - return &fileInfo, nil -} - -func (wfs *WASMFileSystem) Rename(oldPath, newPath string) error { - renameFunc := wfs.module.ExportedFunction("fs_rename") - if renameFunc == nil { - return fmt.Errorf("fs_rename not implemented") - } - - oldPathPtr, oldPathPtrSize, err := writeStringToMemory(wfs.module, oldPath) - if err != nil { - return err - } - defer freeWASMMemory(wfs.module, oldPathPtr, oldPathPtrSize) - - newPathPtr, newPathPtrSize, err := writeStringToMemory(wfs.module, newPath) - if err != nil { - return err - } - defer freeWASMMemory(wfs.module, newPathPtr, newPathPtrSize) - - results, err := renameFunc.Call(wfs.ctx, uint64(oldPathPtr), uint64(newPathPtr)) - if err != nil { - return fmt.Errorf("fs_rename failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("rename failed") - } - - return nil -} - -func (wfs *WASMFileSystem) Chmod(path string, mode uint32) error { - chmodFunc := wfs.module.ExportedFunction("fs_chmod") - if chmodFunc == nil { - // Chmod is optional, silently ignore if not implemented - return nil - } - - pathPtr, pathPtrSize, err := writeStringToMemory(wfs.module, path) - if err != nil { - return err - } - defer freeWASMMemory(wfs.module, pathPtr, pathPtrSize) - - results, err := chmodFunc.Call(wfs.ctx, uint64(pathPtr), uint64(mode)) - if err != nil { - return fmt.Errorf("fs_chmod failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - if errMsg, ok := readStringFromMemory(wfs.module, errPtr); ok { - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("%s", errMsg) - } - freeWASMMemory(wfs.module, errPtr, 0) - return fmt.Errorf("chmod failed") - } - - return nil -} - -func (wfs *WASMFileSystem) Open(path string) (io.ReadCloser, error) { - // For WASM plugins, we can implement Open by reading the entire file - // This is a simple implementation; more sophisticated implementations - // could use streaming or chunked reads - data, err := wfs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(io.NewSectionReader(&bytesReaderAt{data}, 0, int64(len(data)))), nil -} - -func (wfs *WASMFileSystem) OpenWrite(path string) (io.WriteCloser, error) { - // For WASM plugins, we return a WriteCloser that buffers writes - // and flushes on close - return &wasmWriteCloser{ - fs: wfs, - path: path, - buf: make([]byte, 0), - }, nil -} - -// HandleFS interface implementation for WASM plugins - -// SupportsHandleFS checks if the WASM plugin exports handle functions -func (wfs *WASMFileSystem) SupportsHandleFS() bool { - openFunc := wfs.module.ExportedFunction("handle_open") - return openFunc != nil -} - -// OpenHandle opens a file and returns a handle -func (wfs *WASMFileSystem) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - openFunc := wfs.module.ExportedFunction("handle_open") - if openFunc == nil { - return nil, fmt.Errorf("handle_open not implemented in WASM plugin") - } - - pathPtr, pathPtrSize, err := writeStringToMemoryWithBuffer(wfs.module, path, wfs.sharedBuffer) - if err != nil { - return nil, err - } - defer freeWASMMemoryWithBuffer(wfs.module, pathPtr, pathPtrSize, wfs.sharedBuffer) - - results, err := openFunc.Call(wfs.ctx, uint64(pathPtr), uint64(flags), uint64(mode)) - if err != nil { - return nil, fmt.Errorf("handle_open failed: %w", err) - } - - if len(results) < 1 { - return nil, fmt.Errorf("handle_open returned invalid results") - } - - // Unpack u64: low 32 bits = error ptr, high 32 bits = handle_id (as i64) - // When successful: packed = (handle_id << 32) | 0 - // When error: packed = 0 | error_ptr - packed := results[0] - errPtr := uint32(packed & 0xFFFFFFFF) - handleID := int64(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return nil, fmt.Errorf("open handle failed: %s", errMsg) - } - return nil, fmt.Errorf("open handle failed") - } - - if handleID == 0 { - return nil, fmt.Errorf("handle_open returned zero id") - } - - return &WASMFileHandle{ - wasmID: handleID, - path: path, - flags: flags, - wfs: wfs, - }, nil -} - -// GetHandle retrieves an existing handle by ID -// For WASMFileSystem, this is not directly supported since we use string IDs internally -// The PooledWASMFileSystem layer handles the int64 to internal mapping -func (wfs *WASMFileSystem) GetHandle(id int64) (filesystem.FileHandle, error) { - return nil, fmt.Errorf("WASMFileSystem.GetHandle not supported directly; use PooledWASMFileSystem") -} - -// CloseHandle closes a handle by ID -// For WASMFileSystem, this is not directly supported since we use string IDs internally -func (wfs *WASMFileSystem) CloseHandle(id int64) error { - return fmt.Errorf("WASMFileSystem.CloseHandle not supported directly; use PooledWASMFileSystem") -} - -// Internal handle operation methods - -func (wfs *WASMFileSystem) handleRead(id int64, buf []byte) (int, error) { - readFunc := wfs.module.ExportedFunction("handle_read") - if readFunc == nil { - return 0, fmt.Errorf("handle_read not implemented") - } - - // Allocate buffer in WASM memory (can use shared buffer) - bufPtr, bufPtrSize, err := writeBytesToMemoryWithBuffer(wfs.module, make([]byte, len(buf)), wfs.sharedBuffer) - if err != nil { - return 0, err - } - defer freeWASMMemoryWithBuffer(wfs.module, bufPtr, bufPtrSize, wfs.sharedBuffer) - - results, err := readFunc.Call(wfs.ctx, uint64(id), uint64(bufPtr), uint64(len(buf))) - if err != nil { - return 0, fmt.Errorf("handle_read failed: %w", err) - } - - if len(results) < 1 { - return 0, fmt.Errorf("handle_read returned invalid results") - } - - // Unpack u64: low 32 bits = bytes read, high 32 bits = error ptr - packed := results[0] - bytesRead := uint32(packed & 0xFFFFFFFF) - errPtr := uint32(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return 0, fmt.Errorf("read failed: %s", errMsg) - } - return 0, fmt.Errorf("read failed") - } - - // Copy data from WASM memory to buf - if bytesRead > 0 { - data, ok := wfs.module.Memory().Read(bufPtr, bytesRead) - if !ok { - return 0, fmt.Errorf("failed to read data from WASM memory") - } - copy(buf, data) - } - - return int(bytesRead), nil -} - -func (wfs *WASMFileSystem) handleReadAt(id int64, buf []byte, offset int64) (int, error) { - readAtFunc := wfs.module.ExportedFunction("handle_read_at") - if readAtFunc == nil { - return 0, fmt.Errorf("handle_read_at not implemented") - } - - bufPtr, bufPtrSize, err := writeBytesToMemoryWithBuffer(wfs.module, make([]byte, len(buf)), wfs.sharedBuffer) - if err != nil { - return 0, err - } - defer freeWASMMemoryWithBuffer(wfs.module, bufPtr, bufPtrSize, wfs.sharedBuffer) - - results, err := readAtFunc.Call(wfs.ctx, uint64(id), uint64(bufPtr), uint64(len(buf)), uint64(offset)) - if err != nil { - return 0, fmt.Errorf("handle_read_at failed: %w", err) - } - - if len(results) < 1 { - return 0, fmt.Errorf("handle_read_at returned invalid results") - } - - // Unpack u64: low 32 bits = bytes read, high 32 bits = error ptr - packed := results[0] - bytesRead := uint32(packed & 0xFFFFFFFF) - errPtr := uint32(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return 0, fmt.Errorf("read at failed: %s", errMsg) - } - return 0, fmt.Errorf("read at failed") - } - - if bytesRead > 0 { - data, ok := wfs.module.Memory().Read(bufPtr, bytesRead) - if !ok { - return 0, fmt.Errorf("failed to read data from WASM memory") - } - copy(buf, data) - } - - return int(bytesRead), nil -} - -func (wfs *WASMFileSystem) handleWrite(id int64, data []byte) (int, error) { - writeFunc := wfs.module.ExportedFunction("handle_write") - if writeFunc == nil { - return 0, fmt.Errorf("handle_write not implemented") - } - - dataPtr, dataPtrSize, err := writeBytesToMemoryWithBuffer(wfs.module, data, wfs.sharedBuffer) - if err != nil { - return 0, err - } - defer freeWASMMemoryWithBuffer(wfs.module, dataPtr, dataPtrSize, wfs.sharedBuffer) - - results, err := writeFunc.Call(wfs.ctx, uint64(id), uint64(dataPtr), uint64(len(data))) - if err != nil { - return 0, fmt.Errorf("handle_write failed: %w", err) - } - - if len(results) < 1 { - return 0, fmt.Errorf("handle_write returned invalid results") - } - - // Unpack u64: low 32 bits = bytes written, high 32 bits = error ptr - packed := results[0] - bytesWritten := uint32(packed & 0xFFFFFFFF) - errPtr := uint32(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return 0, fmt.Errorf("write failed: %s", errMsg) - } - return 0, fmt.Errorf("write failed") - } - - return int(bytesWritten), nil -} - -func (wfs *WASMFileSystem) handleWriteAt(id int64, data []byte, offset int64) (int, error) { - writeAtFunc := wfs.module.ExportedFunction("handle_write_at") - if writeAtFunc == nil { - return 0, fmt.Errorf("handle_write_at not implemented") - } - - dataPtr, dataPtrSize, err := writeBytesToMemoryWithBuffer(wfs.module, data, wfs.sharedBuffer) - if err != nil { - return 0, err - } - defer freeWASMMemoryWithBuffer(wfs.module, dataPtr, dataPtrSize, wfs.sharedBuffer) - - results, err := writeAtFunc.Call(wfs.ctx, uint64(id), uint64(dataPtr), uint64(len(data)), uint64(offset)) - if err != nil { - return 0, fmt.Errorf("handle_write_at failed: %w", err) - } - - if len(results) < 1 { - return 0, fmt.Errorf("handle_write_at returned invalid results") - } - - // Unpack u64: low 32 bits = bytes written, high 32 bits = error ptr - packed := results[0] - bytesWritten := uint32(packed & 0xFFFFFFFF) - errPtr := uint32(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return 0, fmt.Errorf("write at failed: %s", errMsg) - } - return 0, fmt.Errorf("write at failed") - } - - return int(bytesWritten), nil -} - -func (wfs *WASMFileSystem) handleSeek(id int64, offset int64, whence int) (int64, error) { - seekFunc := wfs.module.ExportedFunction("handle_seek") - if seekFunc == nil { - return 0, fmt.Errorf("handle_seek not implemented") - } - - results, err := seekFunc.Call(wfs.ctx, uint64(id), uint64(offset), uint64(whence)) - if err != nil { - return 0, fmt.Errorf("handle_seek failed: %w", err) - } - - if len(results) < 1 { - return 0, fmt.Errorf("handle_seek returned invalid results") - } - - // Unpack u64: low 32 bits = new position, high 32 bits = error ptr - packed := results[0] - newPos := uint32(packed & 0xFFFFFFFF) - errPtr := uint32(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return 0, fmt.Errorf("seek failed: %s", errMsg) - } - return 0, fmt.Errorf("seek failed") - } - - return int64(newPos), nil -} - -func (wfs *WASMFileSystem) handleSync(id int64) error { - syncFunc := wfs.module.ExportedFunction("handle_sync") - if syncFunc == nil { - return fmt.Errorf("handle_sync not implemented") - } - - results, err := syncFunc.Call(wfs.ctx, uint64(id)) - if err != nil { - return fmt.Errorf("handle_sync failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return fmt.Errorf("sync failed: %s", errMsg) - } - return fmt.Errorf("sync failed") - } - - return nil -} - -func (wfs *WASMFileSystem) handleClose(id int64) error { - closeFunc := wfs.module.ExportedFunction("handle_close") - if closeFunc == nil { - return fmt.Errorf("handle_close not implemented") - } - - results, err := closeFunc.Call(wfs.ctx, uint64(id)) - if err != nil { - return fmt.Errorf("handle_close failed: %w", err) - } - - if len(results) > 0 && results[0] != 0 { - errPtr := uint32(results[0]) - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return fmt.Errorf("close failed: %s", errMsg) - } - return fmt.Errorf("close failed") - } - - return nil -} - -func (wfs *WASMFileSystem) handleStat(id int64) (*filesystem.FileInfo, error) { - statFunc := wfs.module.ExportedFunction("handle_stat") - if statFunc == nil { - return nil, fmt.Errorf("handle_stat not implemented") - } - - results, err := statFunc.Call(wfs.ctx, uint64(id)) - if err != nil { - return nil, fmt.Errorf("handle_stat failed: %w", err) - } - - if len(results) < 1 { - return nil, fmt.Errorf("handle_stat returned invalid results") - } - - // Unpack u64: low 32 bits = json ptr, high 32 bits = error ptr - packed := results[0] - jsonPtr := uint32(packed & 0xFFFFFFFF) - errPtr := uint32(packed >> 32) - - if errPtr != 0 { - errMsg, ok := readStringFromMemory(wfs.module, errPtr) - freeWASMMemory(wfs.module, errPtr, 0) - if ok && errMsg != "" { - return nil, fmt.Errorf("stat failed: %s", errMsg) - } - return nil, fmt.Errorf("stat failed") - } - - if jsonPtr == 0 { - return nil, fmt.Errorf("handle_stat returned null") - } - - jsonStr, ok := readStringFromMemory(wfs.module, jsonPtr) - freeWASMMemory(wfs.module, jsonPtr, 0) - if !ok { - return nil, fmt.Errorf("failed to read stat result") - } - - var fileInfo filesystem.FileInfo - if err := json.Unmarshal([]byte(jsonStr), &fileInfo); err != nil { - return nil, fmt.Errorf("failed to unmarshal stat result: %w", err) - } - - return &fileInfo, nil -} - -// Helper types for Open/OpenWrite implementation - -type wasmWriteCloser struct { - fs *WASMFileSystem - path string - buf []byte -} - -// WASMFileHandle implements filesystem.FileHandle for WASM plugins -// Note: id is the internal handle ID used by the WASM plugin (string) -// The ID() method returns a placeholder since WASMFileHandle is wrapped by PooledWASMFileHandle -type WASMFileHandle struct { - wasmID int64 // WASM plugin's internal handle ID - path string - flags filesystem.OpenFlag - wfs *WASMFileSystem - closed bool -} - -// ID returns -1 since WASMFileHandle is always wrapped by PooledWASMFileHandle -// The real int64 ID is provided by PooledWASMFileHandle -func (h *WASMFileHandle) ID() int64 { - return -1 // Should never be called directly; use PooledWASMFileHandle.ID() -} - -// Path returns the file path -func (h *WASMFileHandle) Path() string { - return h.path -} - -// Flags returns the open flags -func (h *WASMFileHandle) Flags() filesystem.OpenFlag { - return h.flags -} - -// Read reads from the current position -func (h *WASMFileHandle) Read(buf []byte) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.wfs.handleRead(h.wasmID, buf) -} - -// ReadAt reads at a specific offset -func (h *WASMFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.wfs.handleReadAt(h.wasmID, buf, offset) -} - -// Write writes at the current position -func (h *WASMFileHandle) Write(data []byte) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.wfs.handleWrite(h.wasmID, data) -} - -// WriteAt writes at a specific offset -func (h *WASMFileHandle) WriteAt(data []byte, offset int64) (int, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.wfs.handleWriteAt(h.wasmID, data, offset) -} - -// Seek changes the file position -func (h *WASMFileHandle) Seek(offset int64, whence int) (int64, error) { - if h.closed { - return 0, fmt.Errorf("handle is closed") - } - return h.wfs.handleSeek(h.wasmID, offset, whence) -} - -// Sync flushes data to storage -func (h *WASMFileHandle) Sync() error { - if h.closed { - return fmt.Errorf("handle is closed") - } - return h.wfs.handleSync(h.wasmID) -} - -// Close closes the handle -func (h *WASMFileHandle) Close() error { - if h.closed { - return nil - } - h.closed = true - return h.wfs.handleClose(h.wasmID) -} - -// Stat returns file info -func (h *WASMFileHandle) Stat() (*filesystem.FileInfo, error) { - if h.closed { - return nil, fmt.Errorf("handle is closed") - } - return h.wfs.handleStat(h.wasmID) -} - -func (w *wasmWriteCloser) Write(p []byte) (n int, err error) { - w.buf = append(w.buf, p...) - return len(p), nil -} - -func (w *wasmWriteCloser) Close() error { - _, err := w.fs.Write(w.path, w.buf, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - return err -} - -// Helper functions for memory management - -// freeWASMMemory frees memory allocated in WASM module -// Supports both standard free(ptr) and Rust-style free(ptr, size) -// If size is 0, tries both calling conventions -// Does not free memory from shared buffers -func freeWASMMemory(module wazeroapi.Module, ptr uint32, size uint32) { - freeWASMMemoryWithBuffer(module, ptr, size, nil) -} - -func freeWASMMemoryWithBuffer(module wazeroapi.Module, ptr uint32, size uint32, bufInfo *SharedBufferInfo) { - if ptr == 0 { - return - } - - // Don't free shared buffer memory - if bufInfo != nil && bufInfo.Enabled { - if ptr == bufInfo.InputBufferPtr || ptr == bufInfo.OutputBufferPtr { - return // This is shared buffer memory, don't free - } - } - - freeFunc := module.ExportedFunction("free") - if freeFunc == nil { - // free function not available, skip silently - // Memory will be reclaimed when instance is destroyed - return - } - - // Try calling with two parameters first (Rust-style: ptr, size) - _, err := freeFunc.Call(context.Background(), uint64(ptr), uint64(size)) - if err != nil { - // If that fails and size is 0, it might be standard C free(ptr) - // Try with single parameter - if size == 0 { - _, err2 := freeFunc.Call(context.Background(), uint64(ptr)) - if err2 != nil { - log.Debugf("free failed with both signatures: two-param(%v), one-param(%v)", err, err2) - } - } else { - log.Debugf("free failed: %v", err) - } - } -} - -// ReadStringFromWASMMemory is exported for use by wasm_loader -func ReadStringFromWASMMemory(module wazeroapi.Module, ptr uint32) (string, bool) { - return readStringFromMemory(module, ptr) -} - -func readStringFromMemory(module wazeroapi.Module, ptr uint32) (string, bool) { - if ptr == 0 { - return "", false - } - - mem := module.Memory() - if mem == nil { - return "", false - } - - // Read until null terminator - var length uint32 - for { - b, ok := mem.ReadByte(ptr + length) - if !ok { - return "", false - } - if b == 0 { - break - } - length++ - } - - if length == 0 { - return "", true - } - - data, ok := mem.Read(ptr, length) - if !ok { - return "", false - } - - return string(data), true -} - -func writeStringToMemory(module wazeroapi.Module, s string) (ptr uint32, size uint32, err error) { - return writeStringToMemoryWithBuffer(module, s, nil) -} - -func writeStringToMemoryWithBuffer(module wazeroapi.Module, s string, bufInfo *SharedBufferInfo) (ptr uint32, size uint32, err error) { - size = uint32(len(s) + 1) // +1 for null terminator - data := append([]byte(s), 0) - - // Try to use shared buffer if available and data fits - if bufInfo != nil && bufInfo.Enabled && size <= bufInfo.BufferSize { - mem := module.Memory() - if mem.Write(bufInfo.InputBufferPtr, data) { - return bufInfo.InputBufferPtr, size, nil - } - } - - // Fall back to malloc for large data or if shared buffer not available - allocFunc := module.ExportedFunction("malloc") - if allocFunc == nil { - return 0, 0, fmt.Errorf("malloc function not found in WASM module") - } - - results, callErr := allocFunc.Call(context.Background(), uint64(size)) - if callErr != nil { - return 0, 0, fmt.Errorf("malloc failed: %w", callErr) - } - - if len(results) == 0 { - return 0, 0, fmt.Errorf("malloc returned no results") - } - - ptr = uint32(results[0]) - if ptr == 0 { - return 0, 0, fmt.Errorf("malloc returned null pointer") - } - - // Write string to memory - mem := module.Memory() - if !mem.Write(ptr, data) { - return 0, 0, fmt.Errorf("failed to write string to memory") - } - - return ptr, size, nil -} - -func writeBytesToMemory(module wazeroapi.Module, data []byte) (ptr uint32, size uint32, err error) { - return writeBytesToMemoryWithBuffer(module, data, nil) -} - -func writeBytesToMemoryWithBuffer(module wazeroapi.Module, data []byte, bufInfo *SharedBufferInfo) (ptr uint32, size uint32, err error) { - size = uint32(len(data)) - - // Try to use shared buffer if available and data fits - if bufInfo != nil && bufInfo.Enabled && size <= bufInfo.BufferSize { - mem := module.Memory() - if mem.Write(bufInfo.InputBufferPtr, data) { - return bufInfo.InputBufferPtr, size, nil - } - } - - // Fall back to malloc for large data or if shared buffer not available - allocFunc := module.ExportedFunction("malloc") - if allocFunc == nil { - return 0, 0, fmt.Errorf("malloc function not found in WASM module") - } - - results, callErr := allocFunc.Call(context.Background(), uint64(size)) - if callErr != nil { - return 0, 0, fmt.Errorf("malloc failed: %w", callErr) - } - - if len(results) == 0 { - return 0, 0, fmt.Errorf("malloc returned no results") - } - - ptr = uint32(results[0]) - if ptr == 0 { - return 0, 0, fmt.Errorf("malloc returned null pointer") - } - - // Write data to memory - mem := module.Memory() - if !mem.Write(ptr, data) { - return 0, 0, fmt.Errorf("failed to write bytes to memory") - } - - return ptr, size, nil -} - diff --git a/third_party/agfs/agfs-server/pkg/plugin/config/validation.go b/third_party/agfs/agfs-server/pkg/plugin/config/validation.go deleted file mode 100644 index 3efb734e9..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/config/validation.go +++ /dev/null @@ -1,230 +0,0 @@ -package config - -import ( - "fmt" - "strconv" - "strings" -) - -// GetStringConfig retrieves a string value from config with a default fallback -func GetStringConfig(config map[string]interface{}, key, defaultValue string) string { - if val, ok := config[key].(string); ok && val != "" { - return val - } - return defaultValue -} - -// GetBoolConfig retrieves a boolean value from config with a default fallback -func GetBoolConfig(config map[string]interface{}, key string, defaultValue bool) bool { - if val, ok := config[key].(bool); ok { - return val - } - return defaultValue -} - -// GetIntConfig retrieves an integer value from config with a default fallback -// Supports int, int64, and float64 types -func GetIntConfig(config map[string]interface{}, key string, defaultValue int) int { - if val, ok := config[key].(int); ok { - return val - } - if val, ok := config[key].(int64); ok { - return int(val) - } - if val, ok := config[key].(float64); ok { - return int(val) - } - return defaultValue -} - -// GetFloat64Config retrieves a float64 value from config with a default fallback -// Supports float64 and int types -func GetFloat64Config(config map[string]interface{}, key string, defaultValue float64) float64 { - if val, ok := config[key].(float64); ok { - return val - } - if val, ok := config[key].(int); ok { - return float64(val) - } - return defaultValue -} - -// RequireString validates that a required string config value is present and non-empty -func RequireString(config map[string]interface{}, key string) (string, error) { - val, ok := config[key].(string) - if !ok || val == "" { - return "", fmt.Errorf("%s is required in configuration", key) - } - return val, nil -} - -// RequireInt validates that a required integer config value is present -// Supports int, int64, and float64 types -func RequireInt(config map[string]interface{}, key string) (int, error) { - if val, ok := config[key].(int); ok { - return val, nil - } - if val, ok := config[key].(int64); ok { - return int(val), nil - } - if val, ok := config[key].(float64); ok { - return int(val), nil - } - return 0, fmt.Errorf("%s is required in configuration and must be an integer", key) -} - -// ValidateStringType checks if a config value is a string type (if present) -func ValidateStringType(config map[string]interface{}, key string) error { - if val, exists := config[key]; exists { - if _, ok := val.(string); !ok { - return fmt.Errorf("%s must be a string", key) - } - } - return nil -} - -// ValidateBoolType checks if a config value is a boolean type (if present) -func ValidateBoolType(config map[string]interface{}, key string) error { - if val, exists := config[key]; exists { - if _, ok := val.(bool); !ok { - return fmt.Errorf("%s must be a boolean", key) - } - } - return nil -} - -// ValidateIntType checks if a config value is an integer type (if present) -// Accepts int, int64, and float64 types -func ValidateIntType(config map[string]interface{}, key string) error { - if val, exists := config[key]; exists { - switch val.(type) { - case int, int64, float64: - return nil - default: - return fmt.Errorf("%s must be an integer", key) - } - } - return nil -} - -// ValidateMapType checks if a config value is a map type (if present) -func ValidateMapType(config map[string]interface{}, key string) error { - if val, exists := config[key]; exists { - if _, ok := val.(map[string]interface{}); !ok { - return fmt.Errorf("%s must be a map", key) - } - } - return nil -} - -// ValidateArrayType checks if a config value is an array/slice type (if present) -func ValidateArrayType(config map[string]interface{}, key string) error { - if val, exists := config[key]; exists { - if _, ok := val.([]interface{}); !ok { - return fmt.Errorf("%s must be an array", key) - } - } - return nil -} - -// ParseSize parses a size string with units (e.g., "512KB", "1MB", "2GB") or a plain number -// Returns size in bytes -func ParseSize(s string) (int64, error) { - s = strings.TrimSpace(strings.ToUpper(s)) - - // Handle pure numbers (bytes) - if val, err := strconv.ParseInt(s, 10, 64); err == nil { - return val, nil - } - - // Parse with unit suffix - units := map[string]int64{ - "B": 1, - "KB": 1024, - "MB": 1024 * 1024, - "GB": 1024 * 1024 * 1024, - "TB": 1024 * 1024 * 1024 * 1024, - } - - for suffix, multiplier := range units { - if strings.HasSuffix(s, suffix) { - numStr := strings.TrimSuffix(s, suffix) - numStr = strings.TrimSpace(numStr) - - // Try parsing as integer first - if val, err := strconv.ParseInt(numStr, 10, 64); err == nil { - return val * multiplier, nil - } - - // Try parsing as float - if val, err := strconv.ParseFloat(numStr, 64); err == nil { - return int64(val * float64(multiplier)), nil - } - } - } - - return 0, fmt.Errorf("invalid size format: %s (expected format: number with optional unit B/KB/MB/GB/TB)", s) -} - -// GetSizeConfig retrieves a size value from config with a default fallback -// Supports string with units (e.g., "512KB"), int, and float64 -func GetSizeConfig(config map[string]interface{}, key string, defaultBytes int64) (int64, error) { - val, exists := config[key] - if !exists { - return defaultBytes, nil - } - - switch v := val.(type) { - case string: - return ParseSize(v) - case int: - return int64(v), nil - case int64: - return v, nil - case float64: - return int64(v), nil - default: - return 0, fmt.Errorf("%s must be a size string (e.g., '512KB') or number", key) - } -} - -// GetPortConfig retrieves a port value from config with a default fallback -// Supports string, int, and float64 types -func GetPortConfig(config map[string]interface{}, key, defaultPort string) string { - if port, ok := config[key].(string); ok && port != "" { - return port - } - if portInt, ok := config[key].(int); ok { - return fmt.Sprintf("%d", portInt) - } - if portFloat, ok := config[key].(float64); ok { - return fmt.Sprintf("%d", int(portFloat)) - } - return defaultPort -} - -// ValidateOnlyKnownKeys checks that config only contains keys from the allowedKeys list -// Returns an error if any unknown keys are found -func ValidateOnlyKnownKeys(config map[string]interface{}, allowedKeys []string) error { - // Create a map for fast lookup - allowed := make(map[string]bool) - for _, key := range allowedKeys { - allowed[key] = true - } - - // Check for unknown keys - var unknownKeys []string - for key := range config { - if !allowed[key] { - unknownKeys = append(unknownKeys, key) - } - } - - if len(unknownKeys) > 0 { - return fmt.Errorf("unknown configuration parameter(s) '%s' - allowed parameters are: '%s'", - strings.Join(unknownKeys, "', '"), - strings.Join(allowedKeys, "', '")) - } - - return nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/loader/loader.go b/third_party/agfs/agfs-server/pkg/plugin/loader/loader.go deleted file mode 100644 index e2f749017..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/loader/loader.go +++ /dev/null @@ -1,446 +0,0 @@ -package loader - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - "github.com/ebitengine/purego" - log "github.com/sirupsen/logrus" -) - -// PluginType represents the type of plugin -type PluginType int - -const ( - // PluginTypeUnknown represents an unknown plugin type - PluginTypeUnknown PluginType = iota - // PluginTypeNative represents a native shared library plugin (.so, .dylib, .dll) - PluginTypeNative - // PluginTypeWASM represents a WebAssembly plugin (.wasm) - PluginTypeWASM -) - -// String returns the string representation of the plugin type -func (pt PluginType) String() string { - switch pt { - case PluginTypeNative: - return "native" - case PluginTypeWASM: - return "wasm" - default: - return "unknown" - } -} - -// LoadedPlugin tracks a loaded external plugin -type LoadedPlugin struct { - Path string - Plugin plugin.ServicePlugin - LibHandle uintptr - RefCount int - mu sync.Mutex -} - -// PluginLoader manages loading and unloading of external plugins -type PluginLoader struct { - loadedPlugins map[string]*LoadedPlugin - wasmLoader *WASMPluginLoader - poolConfig api.PoolConfig // Configuration for WASM instance pools - mu sync.RWMutex -} - -// NewPluginLoader creates a new plugin loader with the specified pool configuration -func NewPluginLoader(poolConfig api.PoolConfig) *PluginLoader { - return &PluginLoader{ - loadedPlugins: make(map[string]*LoadedPlugin), - wasmLoader: NewWASMPluginLoader(), - poolConfig: poolConfig, - } -} - - -// DetectPluginType detects the type of plugin based on file content and extension -func DetectPluginType(libraryPath string) (PluginType, error) { - // Check if file exists - if _, err := os.Stat(libraryPath); err != nil { - return PluginTypeUnknown, fmt.Errorf("plugin file not found: %w", err) - } - - // Try to read file magic number - file, err := os.Open(libraryPath) - if err != nil { - return PluginTypeUnknown, fmt.Errorf("failed to open plugin file: %w", err) - } - defer file.Close() - - // Read first 4 bytes for magic number detection - magic := make([]byte, 4) - n, err := file.Read(magic) - if err != nil || n < 4 { - // If we can't read magic, fall back to extension - return detectPluginTypeByExtension(libraryPath), nil - } - - // Check WASM magic number: 0x00 0x61 0x73 0x6D ("\0asm") - if magic[0] == 0x00 && magic[1] == 0x61 && magic[2] == 0x73 && magic[3] == 0x6D { - return PluginTypeWASM, nil - } - - // Check ELF magic number: 0x7F 'E' 'L' 'F' (Linux .so) - if magic[0] == 0x7F && magic[1] == 'E' && magic[2] == 'L' && magic[3] == 'F' { - return PluginTypeNative, nil - } - - // Check Mach-O magic numbers (macOS .dylib) - // 32-bit: 0xFE 0xED 0xFA 0xCE or 0xCE 0xFA 0xED 0xFE - // 64-bit: 0xFE 0xED 0xFA 0xCF or 0xCF 0xFA 0xED 0xFE - // Fat binary: 0xCA 0xFE 0xBA 0xBE or 0xBE 0xBA 0xFE 0xCA - if (magic[0] == 0xFE && magic[1] == 0xED && magic[2] == 0xFA && (magic[3] == 0xCE || magic[3] == 0xCF)) || - (magic[0] == 0xCE && magic[1] == 0xFA && magic[2] == 0xED && magic[3] == 0xFE) || - (magic[0] == 0xCF && magic[1] == 0xFA && magic[2] == 0xED && magic[3] == 0xFE) || - (magic[0] == 0xCA && magic[1] == 0xFE && magic[2] == 0xBA && magic[3] == 0xBE) || - (magic[0] == 0xBE && magic[1] == 0xBA && magic[2] == 0xFE && magic[3] == 0xCA) { - return PluginTypeNative, nil - } - - // Check PE magic number: 'M' 'Z' (Windows .dll) - first 2 bytes - if magic[0] == 'M' && magic[1] == 'Z' { - return PluginTypeNative, nil - } - - // Fall back to extension-based detection - return detectPluginTypeByExtension(libraryPath), nil -} - -// detectPluginTypeByExtension detects plugin type based on file extension (fallback) -func detectPluginTypeByExtension(libraryPath string) PluginType { - ext := strings.ToLower(filepath.Ext(libraryPath)) - switch ext { - case ".wasm": - return PluginTypeWASM - case ".so", ".dylib", ".dll": - return PluginTypeNative - default: - return PluginTypeUnknown - } -} - -// LoadPluginWithType loads a plugin with an explicitly specified type -// For WASM plugins, optional hostFS can be provided to allow access to host filesystem -func (pl *PluginLoader) LoadPluginWithType(libraryPath string, pluginType PluginType, hostFS ...interface{}) (plugin.ServicePlugin, error) { - log.Debugf("Loading plugin with type %s: %s", pluginType, libraryPath) - - // Load based on specified type - switch pluginType { - case PluginTypeWASM: - return pl.wasmLoader.LoadWASMPlugin(libraryPath, pl.poolConfig, hostFS...) - case PluginTypeNative: - return pl.loadNativePlugin(libraryPath) - default: - return nil, fmt.Errorf("unsupported plugin type: %s", pluginType) - } -} - -// LoadPlugin loads a plugin from a shared library file (.so, .dylib, .dll) or WASM file (.wasm) -// The plugin type is automatically detected based on file magic number and extension -func (pl *PluginLoader) LoadPlugin(libraryPath string) (plugin.ServicePlugin, error) { - // Detect plugin type - pluginType, err := DetectPluginType(libraryPath) - if err != nil { - return nil, fmt.Errorf("failed to detect plugin type: %w", err) - } - - log.Debugf("Auto-detected plugin type: %s for %s", pluginType, libraryPath) - - // Use LoadPluginWithType for actual loading - return pl.LoadPluginWithType(libraryPath, pluginType) -} - -// loadNativePlugin loads a native shared library plugin -func (pl *PluginLoader) loadNativePlugin(libraryPath string) (plugin.ServicePlugin, error) { - pl.mu.Lock() - defer pl.mu.Unlock() - - // Check if already loaded - absPath, err := filepath.Abs(libraryPath) - if err != nil { - return nil, fmt.Errorf("failed to resolve path: %w", err) - } - - // For native plugins, if already loaded, create a temp copy - // This allows loading multiple versions of the same file - if _, exists := pl.loadedPlugins[absPath]; exists { - log.Infof("Native plugin %s already loaded, creating new instance from copy", absPath) - - // Create a unique temp copy - tempDir := os.TempDir() - baseName := filepath.Base(libraryPath) - ext := filepath.Ext(baseName) - nameWithoutExt := strings.TrimSuffix(baseName, ext) - - // Find an available filename - counter := 1 - var tempLibPath string - for { - tempLibPath = filepath.Join(tempDir, fmt.Sprintf("%s.%d%s", nameWithoutExt, counter, ext)) - if _, err := os.Stat(tempLibPath); os.IsNotExist(err) { - break - } - counter++ - } - - // Copy file - if err := copyFile(libraryPath, tempLibPath); err != nil { - return nil, fmt.Errorf("failed to create temp copy: %w", err) - } - - // Use the temp path as key - absPath = tempLibPath - log.Infof("Created temp copy at: %s", absPath) - } - - // Open the shared library - libHandle, err := openLibrary(absPath) - if err != nil { - return nil, fmt.Errorf("failed to open library %s: %w", absPath, err) - } - - log.Infof("Loaded library: %s (handle: %v)", absPath, libHandle) - - // Load the plugin functions - vtable, err := loadPluginVTable(libHandle) - if err != nil { - // TODO: Add Dlclose if purego supports it - return nil, fmt.Errorf("failed to load plugin vtable: %w", err) - } - - // Create external plugin wrapper - externalPlugin, err := api.NewExternalPlugin(libHandle, vtable) - if err != nil { - return nil, fmt.Errorf("failed to create plugin wrapper: %w", err) - } - - // Track loaded plugin - loaded := &LoadedPlugin{ - Path: absPath, - Plugin: externalPlugin, - LibHandle: libHandle, - RefCount: 1, - } - pl.loadedPlugins[absPath] = loaded - - log.Infof("Successfully loaded plugin: %s (name: %s)", absPath, externalPlugin.Name()) - return externalPlugin, nil -} - -// UnloadPluginWithType unloads a plugin with an explicitly specified type -func (pl *PluginLoader) UnloadPluginWithType(libraryPath string, pluginType PluginType) error { - log.Debugf("Unloading plugin with type %s: %s", pluginType, libraryPath) - - // Unload based on specified type - switch pluginType { - case PluginTypeWASM: - return pl.wasmLoader.UnloadWASMPlugin(libraryPath) - case PluginTypeNative: - return pl.unloadNativePlugin(libraryPath) - default: - return fmt.Errorf("unsupported plugin type: %s", pluginType) - } -} - -// UnloadPlugin unloads a plugin (decrements ref count, unloads when reaches 0) -// The plugin type is automatically detected based on file magic number and extension -func (pl *PluginLoader) UnloadPlugin(libraryPath string) error { - // Detect plugin type - pluginType, err := DetectPluginType(libraryPath) - if err != nil { - return fmt.Errorf("failed to detect plugin type: %w", err) - } - - // Use UnloadPluginWithType for actual unloading - return pl.UnloadPluginWithType(libraryPath, pluginType) -} - -// unloadNativePlugin unloads a native shared library plugin -func (pl *PluginLoader) unloadNativePlugin(libraryPath string) error { - pl.mu.Lock() - defer pl.mu.Unlock() - - absPath, err := filepath.Abs(libraryPath) - if err != nil { - return fmt.Errorf("failed to resolve path: %w", err) - } - - loaded, exists := pl.loadedPlugins[absPath] - if !exists { - return fmt.Errorf("plugin not loaded: %s", absPath) - } - - loaded.mu.Lock() - loaded.RefCount-- - refCount := loaded.RefCount - loaded.mu.Unlock() - - if refCount <= 0 { - // Shutdown plugin - if err := loaded.Plugin.Shutdown(); err != nil { - log.Warnf("Error shutting down plugin %s: %v", absPath, err) - } - - // Remove from tracking - delete(pl.loadedPlugins, absPath) - - // Note: purego doesn't currently provide Dlclose, so we can't unload the library - // The library will remain in memory until process exit - log.Infof("Unloaded plugin: %s (library remains in memory)", absPath) - } else { - log.Infof("Decremented plugin ref count: %s (refCount: %d)", absPath, refCount) - } - - return nil -} - -// GetLoadedPlugins returns a list of all loaded plugins (both native and WASM) -func (pl *PluginLoader) GetLoadedPlugins() []string { - pl.mu.RLock() - defer pl.mu.RUnlock() - - paths := make([]string, 0, len(pl.loadedPlugins)) - for path := range pl.loadedPlugins { - paths = append(paths, path) - } - - // Add WASM plugins - wasmPaths := pl.wasmLoader.GetLoadedPlugins() - paths = append(paths, wasmPaths...) - - return paths -} - -// GetPluginNameToPathMap returns a map of plugin names to their library paths -func (pl *PluginLoader) GetPluginNameToPathMap() map[string]string { - pl.mu.RLock() - defer pl.mu.RUnlock() - - nameToPath := make(map[string]string) - - // Add native plugins - for path, loaded := range pl.loadedPlugins { - if loaded.Plugin != nil { - nameToPath[loaded.Plugin.Name()] = path - } - } - - // Add WASM plugins - wasmNameToPath := pl.wasmLoader.GetPluginNameToPathMap() - for name, path := range wasmNameToPath { - nameToPath[name] = path - } - - return nameToPath -} - -// IsLoadedWithType checks if a plugin of a specific type is currently loaded -func (pl *PluginLoader) IsLoadedWithType(libraryPath string, pluginType PluginType) bool { - // Check based on specified type - switch pluginType { - case PluginTypeWASM: - return pl.wasmLoader.IsLoaded(libraryPath) - case PluginTypeNative: - return pl.isNativePluginLoaded(libraryPath) - default: - return false - } -} - -// IsLoaded checks if a plugin is currently loaded (both native and WASM) -// The plugin type is automatically detected based on file magic number and extension -func (pl *PluginLoader) IsLoaded(libraryPath string) bool { - // Detect plugin type - pluginType, err := DetectPluginType(libraryPath) - if err != nil { - log.Debugf("Failed to detect plugin type for %s: %v", libraryPath, err) - return false - } - - // Use IsLoadedWithType for actual check - return pl.IsLoadedWithType(libraryPath, pluginType) -} - -// isNativePluginLoaded checks if a native plugin is currently loaded -func (pl *PluginLoader) isNativePluginLoaded(libraryPath string) bool { - pl.mu.RLock() - defer pl.mu.RUnlock() - - absPath, err := filepath.Abs(libraryPath) - if err != nil { - return false - } - - _, exists := pl.loadedPlugins[absPath] - return exists -} - -// loadPluginVTable loads all required function pointers from the library -func loadPluginVTable(libHandle uintptr) (*api.PluginVTable, error) { - vtable := &api.PluginVTable{} - - // Required functions - if err := loadFunc(libHandle, "PluginNew", &vtable.PluginNew); err != nil { - return nil, fmt.Errorf("missing required function PluginNew: %w", err) - } - - // Optional lifecycle functions - loadFunc(libHandle, "PluginFree", &vtable.PluginFree) - loadFunc(libHandle, "PluginName", &vtable.PluginName) - loadFunc(libHandle, "PluginValidate", &vtable.PluginValidate) - loadFunc(libHandle, "PluginInitialize", &vtable.PluginInitialize) - loadFunc(libHandle, "PluginShutdown", &vtable.PluginShutdown) - loadFunc(libHandle, "PluginGetReadme", &vtable.PluginGetReadme) - - // Optional filesystem functions - loadFunc(libHandle, "FSCreate", &vtable.FSCreate) - loadFunc(libHandle, "FSMkdir", &vtable.FSMkdir) - loadFunc(libHandle, "FSRemove", &vtable.FSRemove) - loadFunc(libHandle, "FSRemoveAll", &vtable.FSRemoveAll) - loadFunc(libHandle, "FSRead", &vtable.FSRead) - loadFunc(libHandle, "FSWrite", &vtable.FSWrite) - loadFunc(libHandle, "FSReadDir", &vtable.FSReadDir) - loadFunc(libHandle, "FSStat", &vtable.FSStat) - loadFunc(libHandle, "FSRename", &vtable.FSRename) - loadFunc(libHandle, "FSChmod", &vtable.FSChmod) - - return vtable, nil -} - -// loadFunc loads a single function from the library -func loadFunc(libHandle uintptr, name string, fptr interface{}) error { - defer func() { - if r := recover(); r != nil { - log.Debugf("Function %s not found in library (this may be ok if optional)", name) - } - }() - - purego.RegisterLibFunc(fptr, libHandle, name) - return nil -} - -// copyFile copies a file from src to dst -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - - err = os.WriteFile(dst, data, 0755) - if err != nil { - return err - } - - return nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/loader/loader_unix.go b/third_party/agfs/agfs-server/pkg/plugin/loader/loader_unix.go deleted file mode 100644 index a15fb24e8..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/loader/loader_unix.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build !windows - -package loader - -import "github.com/ebitengine/purego" - -func openLibrary(path string) (uintptr, error) { - // RTLD_NOW = resolve all symbols immediately - // RTLD_LOCAL = symbols not available for subsequently loaded libraries - const ( - RTLD_NOW = 0x2 - RTLD_LOCAL = 0x0 - ) - return purego.Dlopen(path, RTLD_NOW|RTLD_LOCAL) -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/loader/loader_windows.go b/third_party/agfs/agfs-server/pkg/plugin/loader/loader_windows.go deleted file mode 100644 index 62648fb2d..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/loader/loader_windows.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build windows - -package loader - -import "syscall" - -func openLibrary(path string) (uintptr, error) { - handle, err := syscall.LoadLibrary(path) - return uintptr(handle), err -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/loader/registry.go b/third_party/agfs/agfs-server/pkg/plugin/loader/registry.go deleted file mode 100644 index 3bd0599cf..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/loader/registry.go +++ /dev/null @@ -1,167 +0,0 @@ -package loader - -import ( - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - - log "github.com/sirupsen/logrus" -) - -// PluginInfo contains metadata about a discovered plugin -type PluginInfo struct { - Path string - Name string - Type PluginType - IsLoaded bool -} - -// DiscoverPlugins searches for plugin files in a directory (both native and WASM) -func DiscoverPlugins(dir string) ([]PluginInfo, error) { - if dir == "" { - return []PluginInfo{}, nil - } - - // Check if directory exists - stat, err := os.Stat(dir) - if err != nil { - if os.IsNotExist(err) { - return []PluginInfo{}, nil - } - return nil, fmt.Errorf("failed to stat plugin directory: %w", err) - } - - if !stat.IsDir() { - return nil, fmt.Errorf("plugin path is not a directory: %s", dir) - } - - // Get plugin extension for current platform - nativeExt := getPluginExtension() - wasmExt := ".wasm" - - // Find all plugin files - var plugins []PluginInfo - - err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { - if err != nil { - log.Warnf("Error accessing path %s: %v", path, err) - return nil // Continue walking - } - - if info.IsDir() { - return nil - } - - // Check if file has plugin extension (native or WASM) - var pluginType PluginType - var name string - - if strings.HasSuffix(info.Name(), nativeExt) { - pluginType = PluginTypeNative - name = strings.TrimSuffix(info.Name(), nativeExt) - } else if strings.HasSuffix(info.Name(), wasmExt) { - pluginType = PluginTypeWASM - name = strings.TrimSuffix(info.Name(), wasmExt) - } else { - // Not a plugin file, skip - return nil - } - - plugins = append(plugins, PluginInfo{ - Path: path, - Name: name, - Type: pluginType, - IsLoaded: false, - }) - - return nil - }) - - if err != nil { - return nil, fmt.Errorf("failed to walk plugin directory: %w", err) - } - - log.Infof("Discovered %d plugin(s) in %s (%d native, %d WASM)", - len(plugins), dir, countPluginsByType(plugins, PluginTypeNative), countPluginsByType(plugins, PluginTypeWASM)) - return plugins, nil -} - -// countPluginsByType counts plugins of a specific type -func countPluginsByType(plugins []PluginInfo, pluginType PluginType) int { - count := 0 - for _, p := range plugins { - if p.Type == pluginType { - count++ - } - } - return count -} - -// getPluginExtension returns the shared library extension for the current platform -func getPluginExtension() string { - switch runtime.GOOS { - case "darwin": - return ".dylib" - case "linux": - return ".so" - case "windows": - return ".dll" - default: - return ".so" - } -} - -// LoadPluginsFromDirectory loads all plugins from a directory -func (pl *PluginLoader) LoadPluginsFromDirectory(dir string) ([]string, []error) { - plugins, err := DiscoverPlugins(dir) - if err != nil { - return nil, []error{err} - } - - var loaded []string - var errors []error - - for _, pluginInfo := range plugins { - _, err := pl.LoadPlugin(pluginInfo.Path) - if err != nil { - errors = append(errors, fmt.Errorf("failed to load %s: %w", pluginInfo.Name, err)) - log.Errorf("Failed to load plugin %s: %v", pluginInfo.Path, err) - } else { - loaded = append(loaded, pluginInfo.Path) - log.Infof("Loaded plugin: %s", pluginInfo.Name) - } - } - - return loaded, errors -} - -// ValidatePluginPath validates that a plugin path is safe to load -func ValidatePluginPath(path string) error { - // Check if path exists - stat, err := os.Stat(path) - if err != nil { - return fmt.Errorf("plugin file not found: %w", err) - } - - if stat.IsDir() { - return fmt.Errorf("plugin path is a directory, not a file") - } - - // Check extension (either native or WASM) - nativeExt := getPluginExtension() - wasmExt := ".wasm" - if !strings.HasSuffix(path, nativeExt) && !strings.HasSuffix(path, wasmExt) { - return fmt.Errorf("invalid plugin file extension (expected %s or %s)", nativeExt, wasmExt) - } - - // Check file is readable - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("cannot open plugin file: %w", err) - } - file.Close() - - return nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/loader/wasm_loader.go b/third_party/agfs/agfs-server/pkg/plugin/loader/wasm_loader.go deleted file mode 100644 index 6e404b81b..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/loader/wasm_loader.go +++ /dev/null @@ -1,319 +0,0 @@ -package loader - -import ( - "context" - "fmt" - "os" - "path/filepath" - "sync" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" - log "github.com/sirupsen/logrus" - "github.com/tetratelabs/wazero" - wazeroapi "github.com/tetratelabs/wazero/api" - "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" -) - -// LoadedWASMPlugin tracks a loaded WASM plugin -type LoadedWASMPlugin struct { - Path string - Plugin plugin.ServicePlugin - Runtime wazero.Runtime - RefCount int - mu sync.Mutex -} - -// WASMPluginLoader manages loading and unloading of WASM plugins -type WASMPluginLoader struct { - loadedPlugins map[string]*LoadedWASMPlugin - mu sync.RWMutex -} - -// NewWASMPluginLoader creates a new WASM plugin loader -func NewWASMPluginLoader() *WASMPluginLoader { - return &WASMPluginLoader{ - loadedPlugins: make(map[string]*LoadedWASMPlugin), - } -} - -// LoadWASMPlugin loads a plugin from a WASM file -// If hostFS is provided, it will be exposed to the WASM plugin as host functions -// poolConfig specifies the instance pool configuration (use api.PoolConfig{} for defaults) -func (wl *WASMPluginLoader) LoadWASMPlugin(wasmPath string, poolConfig api.PoolConfig, hostFS ...interface{}) (plugin.ServicePlugin, error) { - wl.mu.Lock() - defer wl.mu.Unlock() - - // Check if already loaded - absPath, err := filepath.Abs(wasmPath) - if err != nil { - return nil, fmt.Errorf("failed to resolve path: %w", err) - } - - // For WASM plugins, if already loaded, create a new instance with unique key - // This allows hot reloading of the same WASM file - if _, exists := wl.loadedPlugins[absPath]; exists { - log.Infof("WASM plugin %s already loaded, creating new instance", absPath) - - // Find a unique key for this instance - counter := 1 - var uniqueKey string - for { - uniqueKey = fmt.Sprintf("%s#%d", absPath, counter) - if _, exists := wl.loadedPlugins[uniqueKey]; !exists { - break - } - counter++ - } - absPath = uniqueKey - log.Infof("Using unique key for new WASM instance: %s", absPath) - } - - // Read WASM binary - wasmBytes, err := os.ReadFile(wasmPath) - if err != nil { - return nil, fmt.Errorf("failed to read WASM file %s: %w", wasmPath, err) - } - - // Create a new WASM runtime - ctx := context.Background() - r := wazero.NewRuntime(ctx) - - // Instantiate WASI - if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { - r.Close(ctx) - return nil, fmt.Errorf("failed to instantiate WASI: %w", err) - } - - // Always instantiate host filesystem module (required by WASM modules that import these functions) - // If no hostFS is provided, use stub functions that return errors - var fs filesystem.FileSystem - if len(hostFS) > 0 && hostFS[0] != nil { - // Type assert to filesystem.FileSystem - var ok bool - fs, ok = hostFS[0].(filesystem.FileSystem) - if !ok { - r.Close(ctx) - return nil, fmt.Errorf("hostFS is not a filesystem.FileSystem") - } - log.Infof("Registering host filesystem for WASM plugin") - } else { - log.Infof("No host filesystem provided, using stub functions") - fs = nil // Will be handled by api functions - } - - _, err = r.NewHostModuleBuilder("env"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr uint32, offset, size int64) uint64 { - return api.HostFSRead(ctx, mod, []uint64{uint64(pathPtr), uint64(offset), uint64(size)}, fs)[0] - }). - Export("host_fs_read"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr, dataPtr, dataLen uint32) uint64 { - return api.HostFSWrite(ctx, mod, []uint64{uint64(pathPtr), uint64(dataPtr), uint64(dataLen)}, fs)[0] - }). - Export("host_fs_write"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr uint32) uint64 { - return api.HostFSStat(ctx, mod, []uint64{uint64(pathPtr)}, fs)[0] - }). - Export("host_fs_stat"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr uint32) uint64 { - return api.HostFSReadDir(ctx, mod, []uint64{uint64(pathPtr)}, fs)[0] - }). - Export("host_fs_readdir"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr uint32) uint32 { - return uint32(api.HostFSCreate(ctx, mod, []uint64{uint64(pathPtr)}, fs)[0]) - }). - Export("host_fs_create"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr, perm uint32) uint32 { - return uint32(api.HostFSMkdir(ctx, mod, []uint64{uint64(pathPtr), uint64(perm)}, fs)[0]) - }). - Export("host_fs_mkdir"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr uint32) uint32 { - return uint32(api.HostFSRemove(ctx, mod, []uint64{uint64(pathPtr)}, fs)[0]) - }). - Export("host_fs_remove"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr uint32) uint32 { - return uint32(api.HostFSRemoveAll(ctx, mod, []uint64{uint64(pathPtr)}, fs)[0]) - }). - Export("host_fs_remove_all"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, oldPathPtr, newPathPtr uint32) uint32 { - return uint32(api.HostFSRename(ctx, mod, []uint64{uint64(oldPathPtr), uint64(newPathPtr)}, fs)[0]) - }). - Export("host_fs_rename"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, pathPtr, mode uint32) uint32 { - return uint32(api.HostFSChmod(ctx, mod, []uint64{uint64(pathPtr), uint64(mode)}, fs)[0]) - }). - Export("host_fs_chmod"). - NewFunctionBuilder(). - WithFunc(func(ctx context.Context, mod wazeroapi.Module, requestPtr uint32) uint64 { - return api.HostHTTPRequest(ctx, mod, []uint64{uint64(requestPtr)})[0] - }). - Export("host_http_request"). - Instantiate(ctx) - if err != nil { - r.Close(ctx) - return nil, fmt.Errorf("failed to instantiate host filesystem module: %w", err) - } - - // Compile and instantiate the WASM module - compiledModule, err := r.CompileModule(ctx, wasmBytes) - if err != nil { - r.Close(ctx) - return nil, fmt.Errorf("failed to compile WASM module: %w", err) - } - - // Instantiate the module without filesystem access - // WASM plugins are not allowed to access the local filesystem - config := wazero.NewModuleConfig(). - WithName("plugin"). - WithStdout(os.Stdout). // Enable stdout - WithStderr(os.Stderr) // Enable stderr - - module, err := r.InstantiateModule(ctx, compiledModule, config) - if err != nil { - r.Close(ctx) - return nil, fmt.Errorf("failed to instantiate WASM module: %w", err) - } - - log.Infof("Loaded WASM module: %s", wasmPath) - - // Call plugin_new to initialize and get plugin name - pluginName := "wasm-plugin" - - // First call plugin_new - if newFunc := module.ExportedFunction("plugin_new"); newFunc != nil { - if _, err := newFunc.Call(ctx); err != nil { - module.Close(ctx) - r.Close(ctx) - return nil, fmt.Errorf("failed to call plugin_new: %w", err) - } - } - - // Then get plugin name - if nameFunc := module.ExportedFunction("plugin_name"); nameFunc != nil { - if nameResults, err := nameFunc.Call(ctx); err == nil && len(nameResults) > 0 { - // Read string from memory - if nameStr, ok := api.ReadStringFromWASMMemory(module, uint32(nameResults[0])); ok { - pluginName = nameStr - } - } - } - - // Close the initial module as we'll use the instance pool instead - module.Close(ctx) - - // Create instance pool with provided configuration - instancePool := api.NewWASMInstancePool(ctx, r, compiledModule, pluginName, poolConfig, fs) - - // Create WASM plugin wrapper with pool - wasmPlugin, err := api.NewWASMPluginWithPool(instancePool, pluginName) - if err != nil { - module.Close(ctx) - r.Close(ctx) - return nil, fmt.Errorf("failed to create WASM plugin wrapper: %w", err) - } - - // Track loaded plugin (don't save module as it's already closed) - loaded := &LoadedWASMPlugin{ - Path: absPath, - Plugin: wasmPlugin, - Runtime: r, - RefCount: 1, - } - wl.loadedPlugins[absPath] = loaded - - log.Infof("Successfully loaded WASM plugin: %s (name: %s)", absPath, wasmPlugin.Name()) - return wasmPlugin, nil -} - -// UnloadWASMPlugin unloads a WASM plugin (decrements ref count, unloads when reaches 0) -func (wl *WASMPluginLoader) UnloadWASMPlugin(wasmPath string) error { - wl.mu.Lock() - defer wl.mu.Unlock() - - absPath, err := filepath.Abs(wasmPath) - if err != nil { - return fmt.Errorf("failed to resolve path: %w", err) - } - - loaded, exists := wl.loadedPlugins[absPath] - if !exists { - return fmt.Errorf("WASM plugin not loaded: %s", absPath) - } - - loaded.mu.Lock() - loaded.RefCount-- - refCount := loaded.RefCount - loaded.mu.Unlock() - - if refCount <= 0 { - // Shutdown plugin (this will close the instance pool) - if err := loaded.Plugin.Shutdown(); err != nil { - log.Warnf("Error shutting down WASM plugin %s: %v", absPath, err) - } - - // Close runtime - ctx := context.Background() - if err := loaded.Runtime.Close(ctx); err != nil { - log.Warnf("Error closing WASM runtime %s: %v", absPath, err) - } - - // Remove from tracking - delete(wl.loadedPlugins, absPath) - log.Infof("Unloaded WASM plugin: %s", absPath) - } else { - log.Infof("Decremented WASM plugin ref count: %s (refCount: %d)", absPath, refCount) - } - - return nil -} - -// GetLoadedPlugins returns a list of all loaded WASM plugins -func (wl *WASMPluginLoader) GetLoadedPlugins() []string { - wl.mu.RLock() - defer wl.mu.RUnlock() - - paths := make([]string, 0, len(wl.loadedPlugins)) - for path := range wl.loadedPlugins { - paths = append(paths, path) - } - return paths -} - -// GetPluginNameToPathMap returns a map of WASM plugin names to their library paths -func (wl *WASMPluginLoader) GetPluginNameToPathMap() map[string]string { - wl.mu.RLock() - defer wl.mu.RUnlock() - - nameToPath := make(map[string]string) - for path, loaded := range wl.loadedPlugins { - if loaded.Plugin != nil { - nameToPath[loaded.Plugin.Name()] = path - } - } - return nameToPath -} - -// IsLoaded checks if a WASM plugin is currently loaded -func (wl *WASMPluginLoader) IsLoaded(wasmPath string) bool { - wl.mu.RLock() - defer wl.mu.RUnlock() - - absPath, err := filepath.Abs(wasmPath) - if err != nil { - return false - } - - _, exists := wl.loadedPlugins[absPath] - return exists -} diff --git a/third_party/agfs/agfs-server/pkg/plugin/plugin.go b/third_party/agfs/agfs-server/pkg/plugin/plugin.go deleted file mode 100644 index bc2878a7b..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/plugin.go +++ /dev/null @@ -1,60 +0,0 @@ -package plugin - -import ( - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -// ConfigParameter describes a configuration parameter for a plugin -type ConfigParameter struct { - Name string `json:"name"` // Parameter name - Type string `json:"type"` // Parameter type (string, int, bool, etc.) - Required bool `json:"required"` // Whether the parameter is required - Default string `json:"default"` // Default value (as string) - Description string `json:"description"` // Parameter description -} - -// ServicePlugin defines the interface for a service that can be mounted to a path -// Each plugin acts as a virtual file system providing service-specific operations -type ServicePlugin interface { - // Name returns the plugin name - Name() string - - // Validate validates the plugin configuration before initialization - // This method should check all required parameters and validate their types/values - // Returns an error if the configuration is invalid - Validate(config map[string]interface{}) error - - // Initialize initializes the plugin with optional configuration - // This method is called after Validate succeeds - Initialize(config map[string]interface{}) error - - // GetFileSystem returns the FileSystem implementation for this plugin - // This allows the plugin to handle file operations in a service-specific way - GetFileSystem() filesystem.FileSystem - - // GetReadme returns the README content for this plugin - // This provides documentation about the plugin's functionality and usage - GetReadme() string - - // GetConfigParams returns the list of configuration parameters supported by this plugin - // This provides metadata about what configuration options are available - GetConfigParams() []ConfigParameter - - // Shutdown gracefully shuts down the plugin - Shutdown() error -} - -// MountPoint represents a mounted service plugin -type MountPoint struct { - Path string - Plugin ServicePlugin -} - -// PluginMetadata contains information about a plugin -type PluginMetadata struct { - Name string - Version string - Description string - Author string -} - diff --git a/third_party/agfs/agfs-server/pkg/plugin/utils.go b/third_party/agfs/agfs-server/pkg/plugin/utils.go deleted file mode 100644 index 63436ebfe..000000000 --- a/third_party/agfs/agfs-server/pkg/plugin/utils.go +++ /dev/null @@ -1,35 +0,0 @@ -package plugin - -import "io" - -// ApplyRangeRead applies offset and size to data slice -// Returns io.EOF if offset+size >= len(data) -func ApplyRangeRead(data []byte, offset int64, size int64) ([]byte, error) { - dataLen := int64(len(data)) - - // Validate offset - if offset < 0 { - offset = 0 - } - if offset >= dataLen { - return nil, io.EOF - } - - // Calculate end position - var end int64 - if size < 0 { - // Read all remaining data - end = dataLen - } else { - end = offset + size - if end > dataLen { - end = dataLen - } - } - - result := data[offset:end] - if end >= dataLen { - return result, io.EOF - } - return result, nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/gptfs/gptfs.go b/third_party/agfs/agfs-server/pkg/plugins/gptfs/gptfs.go deleted file mode 100644 index 3e4d3ae7f..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/gptfs/gptfs.go +++ /dev/null @@ -1,562 +0,0 @@ -package gptfs - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/localfs" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "gptfs" -) - -type Gptfs struct { - gptDriver *gptDriver - apiHost string - apiKey string -} - -type Job struct { - ID string `json:"id"` - RequestPath string `json:"request_path"` - ResponsePath string `json:"response_path"` - Data []byte `json:"data"` - Timestamp time.Time `json:"timestamp"` - Status JobStatus `json:"status"` - Error string `json:"error,omitempty"` - Duration time.Duration `json:"duration,omitempty"` -} - -type JobStatus string - -const ( - JobStatusPending JobStatus = "pending" - JobStatusProcessing JobStatus = "processing" - JobStatusCompleted JobStatus = "completed" - JobStatusFailed JobStatus = "failed" -) - -type JobRequest struct { - JobID string `json:"job_id"` - Status string `json:"status"` - Timestamp int64 `json:"timestamp"` - Message string `json:"message,omitempty"` -} - -type gptDriver struct { - client *http.Client - apiKey string - apiHost string - mountPath string - baseFS *localfs.LocalFS // 使用 LocalFS 持久化存储 - - // 异步处理 - jobQueue chan *Job - workers int - wg sync.WaitGroup - ctx context.Context - cancel context.CancelFunc - - // 状态管理 - jobs sync.Map // map[string]*Job - mu sync.RWMutex -} - -func NewGptfs() *Gptfs { - return &Gptfs{} -} - -func (d *gptDriver) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - n, err := d.baseFS.Write(path, data, offset, flags) - if err != nil { - return 0, err - } - - log.Infof("[gptfs] Detected file write in inbox, creating async job: %s", path) - - fileName := filepath.Base(path) - baseName := fileName[:len(fileName)-len(filepath.Ext(fileName))] - responseFile := filepath.Join("outbox", baseName+"_response.txt") - jobStatusFile := filepath.Join("outbox", baseName+"_status.json") - - jobID := d.generateJobID() - - job := &Job{ - ID: jobID, - RequestPath: path, - ResponsePath: responseFile, - Data: data, - Timestamp: time.Now(), - Status: JobStatusPending, - } - - d.jobs.Store(job.ID, job) - - d.writeJobStatus(jobStatusFile, JobRequest{ - JobID: job.ID, - Status: string(JobStatusPending), - Timestamp: time.Now().Unix(), - Message: "Job queued for processing", - }) - - select { - case d.jobQueue <- job: - log.Infof("[gptfs] Job %s queued successfully", job.ID) - default: - errorMsg := "job queue is full, please try again later" - job.Status = JobStatusFailed - job.Error = errorMsg - log.Warnf("[gptfs] Job %s rejected: %s", job.ID, errorMsg) - - d.writeJobStatus(jobStatusFile, JobRequest{ - JobID: job.ID, - Status: string(JobStatusFailed), - Timestamp: time.Now().Unix(), - Message: errorMsg, - }) - } - - return n, nil -} - -func (d *gptDriver) generateJobID() string { - return fmt.Sprintf("job_%d", time.Now().UnixNano()) -} - -func (d *gptDriver) writeJobStatus(statusFile string, req JobRequest) { - data, err := json.MarshalIndent(req, "", " ") - if err != nil { - log.Errorf("[gptfs] Failed to marshal job status: %v", err) - return - } - - _, err = d.baseFS.Write(statusFile, data, -1, - filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - log.Errorf("[gptfs] Failed to write job status: %v", err) - } -} - -func (d *gptDriver) startWorkers() { - for i := 0; i < d.workers; i++ { - d.wg.Add(1) - go d.worker(i) - } - log.Infof("[gptfs] Started %d workers", d.workers) -} - -func (d *gptDriver) worker(workerID int) { - defer d.wg.Done() - - log.Infof("[gptfs] Worker %d started", workerID) - - for { - select { - case job := <-d.jobQueue: - log.Infof("[gptfs] Worker %d processing job %s", workerID, job.ID) - d.processJob(job) - case <-d.ctx.Done(): - log.Infof("[gptfs] Worker %d shutting down", workerID) - return - } - } -} - -func (d *gptDriver) processJob(job *Job) { - startTime := time.Now() - job.Status = JobStatusProcessing - - // Use the same base name as the response file for status, e.g., outbox/<base>_status.json - dir := filepath.Dir(job.ResponsePath) - base := strings.TrimSuffix(filepath.Base(job.ResponsePath), "_response.txt") - jobStatusFile := filepath.Join(dir, base+"_status.json") - - d.writeJobStatus(jobStatusFile, JobRequest{ - JobID: job.ID, - Status: string(JobStatusProcessing), - Timestamp: time.Now().Unix(), - Message: "Processing request...", - }) - - response, err := d.callOpenAI(job.Data) - if err != nil { - job.Duration = time.Since(startTime) - job.Status = JobStatusFailed - job.Error = err.Error() - - log.Errorf("[gptfs] Job %s failed: %v", job.ID, err) - - d.writeJobStatus(jobStatusFile, JobRequest{ - JobID: job.ID, - Status: string(JobStatusFailed), - Timestamp: time.Now().Unix(), - Message: fmt.Sprintf("API call failed: %s", err.Error()), - }) - return - } - - _, err = d.baseFS.Write(job.ResponsePath, response, -1, - filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - job.Duration = time.Since(startTime) - job.Status = JobStatusFailed - job.Error = err.Error() - - log.Errorf("[gptfs] Job %s failed to write response: %v", job.ID, err) - - d.writeJobStatus(jobStatusFile, JobRequest{ - JobID: job.ID, - Status: string(JobStatusFailed), - Timestamp: time.Now().Unix(), - Message: fmt.Sprintf("Failed to write response: %s", err.Error()), - }) - return - } - - job.Duration = time.Since(startTime) - job.Status = JobStatusCompleted - - log.Infof("[gptfs] Job %s completed in %v", job.ID, job.Duration) - - d.writeJobStatus(jobStatusFile, JobRequest{ - JobID: job.ID, - Status: string(JobStatusCompleted), - Timestamp: time.Now().Unix(), - Message: fmt.Sprintf("Completed in %v", job.Duration), - }) -} - -func (d *gptDriver) callOpenAI(reqBody []byte) ([]byte, error) { - const maxRetries = 3 - var lastErr error - - for attempt := 0; attempt < maxRetries; attempt++ { - if attempt > 0 { - backoff := time.Duration(attempt) * time.Second - log.Warnf("[gptfs] API call attempt %d failed, retrying in %v: %v", - attempt+1, backoff, lastErr) - time.Sleep(backoff) - } - - response, err := d.doAPICall(reqBody) - if err == nil { - return response, nil - } - lastErr = err - - if !isRetryableError(err) { - break - } - } - - return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr) -} - -func (d *gptDriver) doAPICall(reqBody []byte) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(ctx, "POST", d.apiHost, bytes.NewReader(reqBody)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Authorization", "Bearer "+d.apiKey) - req.Header.Set("Content-Type", "application/json") - - resp, err := d.client.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var openaiResp struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` - } - - if err := json.Unmarshal(body, &openaiResp); err == nil && len(openaiResp.Choices) > 0 { - content := openaiResp.Choices[0].Message.Content - log.Infof("[gptfs] Successfully extracted content (%d bytes)", len(content)) - return []byte(content), nil - } - - log.Warnf("[gptfs] Could not extract OpenAI content, returning raw response") - return body, nil -} - -func isRetryableError(err error) bool { - errStr := err.Error() - retryableErrors := []string{ - "timeout", - "connection refused", - "temporary failure", - "network is unreachable", - "no such host", - "connection reset", - "502", // Bad Gateway - "503", // Service Unavailable - "504", // Gateway Timeout - "429", // Too Many Requests - } - - for _, retryable := range retryableErrors { - if strings.Contains(strings.ToLower(errStr), retryable) { - return true - } - } - return false -} - -func (d *gptDriver) Create(path string) error { - return d.baseFS.Create(path) -} - -func (d *gptDriver) Mkdir(path string, perm uint32) error { - return d.baseFS.Mkdir(path, perm) -} - -func (d *gptDriver) RemoveAll(path string) error { - return d.baseFS.RemoveAll(path) -} - -func (d *gptDriver) ReadDir(path string) ([]filesystem.FileInfo, error) { - return d.baseFS.ReadDir(path) -} - -func (d *gptDriver) Rename(oldPath, newPath string) error { - return d.baseFS.Rename(oldPath, newPath) -} - -func (d *gptDriver) Chmod(path string, mode uint32) error { - return d.baseFS.Chmod(path, mode) -} - -func (d *gptDriver) Open(path string) (io.ReadCloser, error) { - return d.baseFS.Open(path) -} - -func (d *gptDriver) OpenWrite(path string) (io.WriteCloser, error) { - return d.baseFS.OpenWrite(path) -} - -func (d *gptDriver) Read(path string, offset int64, size int64) ([]byte, error) { - return d.baseFS.Read(path, offset, size) -} - -func (d *gptDriver) Remove(path string) error { - return d.baseFS.Remove(path) -} - -func (d *gptDriver) Stat(path string) (*filesystem.FileInfo, error) { - return d.baseFS.Stat(path) -} - -func (g *Gptfs) Name() string { - return PluginName -} - -func (g *Gptfs) Validate(cfg map[string]interface{}) error { - allowedKeys := []string{"api_host", "api_key", "mount_path", "workers"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - if _, err := config.RequireString(cfg, "api_key"); err != nil { - return err - } - - if _, err := config.RequireString(cfg, "api_host"); err != nil { - return err - } - - if _, err := config.RequireString(cfg, "mount_path"); err != nil { - return err - } - - return nil -} - -func (g *Gptfs) Initialize(config map[string]interface{}) error { - apiKey := config["api_key"].(string) - apiHost := config["api_host"].(string) - mountPath := config["mount_path"].(string) - - if err := os.MkdirAll(mountPath, 0755); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already exists") { - return fmt.Errorf("failed to create inbox directory: %w", err) - } - } - - baseFS, err := localfs.NewLocalFS(mountPath) - if err != nil { - return fmt.Errorf("failed to initialize localfs: %w", err) - } - - if err := baseFS.Mkdir("inbox", 0755); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already exists") { - return fmt.Errorf("failed to create inbox directory: %w", err) - } - } - if err := baseFS.Mkdir("outbox", 0755); err != nil { - if !strings.Contains(strings.ToLower(err.Error()), "already exists") { - return fmt.Errorf("failed to create outbox directory: %w", err) - } - } - - workers := 3 - if w, ok := config["workers"].(int); ok && w > 0 { - workers = w - } - - ctx, cancel := context.WithCancel(context.Background()) - - driver := &gptDriver{ - client: &http.Client{Transport: &http.Transport{}}, - apiKey: apiKey, - apiHost: apiHost, - mountPath: mountPath, - baseFS: baseFS, - jobQueue: make(chan *Job, 100), // 缓冲队列 - workers: workers, - ctx: ctx, - cancel: cancel, - } - - driver.startWorkers() - - g.gptDriver = driver - g.apiKey = apiKey - g.apiHost = apiHost - - log.Infof("[gptfs] Initialized with mounth=%s, workers=%d", mountPath, workers) - return nil -} - -func (g *Gptfs) GetFileSystem() filesystem.FileSystem { - return g.gptDriver -} - -func (g *Gptfs) GetReadme() string { - return `GPTFS Plugin - Async GPT Processing over Persistent Storage - -This plugin provides an asynchronous interface to OpenAI-compatible APIs -with persistent file storage using LocalFS. - -PATH LAYOUT: - /agents/gptfs/ - inbox/ # Write any file here to trigger API calls - request.json # Example: OpenAI request -> request_response.txt - prompt.txt # Example: Text prompt -> prompt_response.txt - query.md # Example: Markdown query -> query_response.txt - outbox/ - request_response.txt # Response for request.json - request_status.json # Status for request.json - prompt_response.txt # Response for prompt.txt - prompt_status.json # Status for prompt.txt - query_response.txt # Response for query.md - query_status.json # Status for query.md - -WORKFLOW: - 1) Write any file to the gptfs mount path (e.g., inbox/request.json) - 2) File write returns immediately (async processing) - 3) Monitor outbox/{filename}_status.json for progress - 4) Read response from outbox/{filename}_response.txt when complete - -EXAMPLE: - # Write an OpenAI request - echo '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say hello"}]}' > inbox/request.json - # -> Creates outbox/request_response.txt and outbox/request_status.json - - # Write a text prompt - echo "Tell me a joke" > inbox/prompt.txt - # -> Creates outbox/prompt_response.txt and outbox/prompt_status.json - - # Write multiple requests concurrently - echo "What is AI?" > inbox/qa1.txt - echo "What is ML?" > inbox/qa2.txt - # -> Creates separate response and status files for each - -CONFIGURATION: - api_host - OpenAI-compatible endpoint - api_key - API authorization key - data_dir - Persistent storage directory - workers - Concurrent API workers (default: 3) - mount_path - Virtual mount path - -FEATURES: - - Asynchronous processing (non-blocking writes) - - Persistent storage using LocalFS - - Real-time job status tracking - - Automatic retry with exponential backoff - - Multiple concurrent workers - - Detailed error handling and logging -` -} - -func (g *Gptfs) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "api_key", - Type: "string", - Required: true, - Description: "API key for OpenAI-compatible service", - }, - { - Name: "api_host", - Type: "string", - Required: true, - Description: "OpenAI-compatible endpoint URL", - }, - { - Name: "data_dir", - Type: "string", - Required: true, - Description: "Directory for persistent storage", - }, - { - Name: "workers", - Type: "int", - Required: false, - Default: "3", - Description: "Number of concurrent API workers", - }, - } -} - -func (g *Gptfs) Shutdown() error { - if g.gptDriver != nil { - log.Infof("[gptfs] Shutting down, stopping workers...") - g.gptDriver.cancel() - g.gptDriver.wg.Wait() - close(g.gptDriver.jobQueue) - } - return nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/gptfs/gptfs_test.go b/third_party/agfs/agfs-server/pkg/plugins/gptfs/gptfs_test.go deleted file mode 100644 index 412a7d50a..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/gptfs/gptfs_test.go +++ /dev/null @@ -1,492 +0,0 @@ -package gptfs - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -func TestGptfsAsyncProcessing(t *testing.T) { - // Create temp directory for testing - tempDir, err := os.MkdirTemp("", "gptfs-test") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - // Use real temp directory as mount path - mountPath := tempDir - - // Mock OpenAI server - var gotReqBody []byte - var requestCount int - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requestCount++ - if r.Method != http.MethodPost { - t.Fatalf("want POST, got %s", r.Method) - } - if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { - t.Fatalf("unexpected Authorization header: %q", auth) - } - if ct := r.Header.Get("Content-Type"); ct != "application/json" { - t.Fatalf("unexpected Content-Type: %q", ct) - } - b, _ := io.ReadAll(r.Body) - _ = r.Body.Close() - gotReqBody = b - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello world"}}]}`)) - })) - defer ts.Close() - - // Initialize GPTFS - config := map[string]interface{}{ - "api_host": ts.URL, - "api_key": "test-key", - "mount_path": mountPath, - "workers": 1, - } - - g := NewGptfs() - if err := g.Validate(config); err != nil { - t.Fatalf("Validate config: %v", err) - } - if err := g.Initialize(config); err != nil { - t.Fatalf("Initialize GPTFS: %v", err) - } - defer g.Shutdown() - - fs := g.GetFileSystem() - - // Test 1: Write request file - payload := map[string]any{ - "model": "gpt-4o-mini", - "messages": []map[string]string{{"role": "user", "content": "ping"}}, - } - data, _ := json.Marshal(payload) - requestPath := "inbox/request.json" // Use relative path - - if _, err := fs.Write(requestPath, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate); err != nil { - t.Fatalf("write request.json: %v", err) - } - - // Write should return immediately (async) - if requestCount != 0 { - t.Fatalf("expected async processing, but API was called immediately") - } - - // Wait for async processing - timeout := time.After(5 * time.Second) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - var responseContent string - var statusContent string - - for { - select { - case <-timeout: - // Debug: list all files in outbox - outboxPath := "outbox" - if files, err := fs.ReadDir(outboxPath); err == nil { - t.Logf("Files in outbox: %+v", files) - } - t.Fatalf("timeout waiting for response. Response: %q, Status: %q", responseContent, statusContent) - case <-ticker.C: - // Check response file - responsePath := "outbox/request_response.txt" - if response, err := fs.Read(responsePath, 0, -1); err == nil { - responseContent = string(response) - t.Logf("Found response content: %q", responseContent) - } else if err == io.EOF && response != nil { - // File exists and has some data before EOF - responseContent = string(response) - t.Logf("Found response content before EOF: %q", responseContent) - } else if err == io.EOF { - // File exists but is empty, ignore for now - t.Logf("Response file exists but empty") - } else { - t.Logf("Response file read error: %v", err) - } - - // Check status file - statusPath := "outbox/request_status.json" - if status, err := fs.Read(statusPath, 0, -1); err == nil { - statusContent = string(status) - t.Logf("Found status content: %q", statusContent) - } else if err == io.EOF && status != nil { - // File exists and has some data before EOF - statusContent = string(status) - t.Logf("Found status content before EOF: %q", statusContent) - } else if err == io.EOF { - // File exists but is empty, ignore for now - t.Logf("Status file exists but empty") - } else { - t.Logf("Status file read error: %v", err) - } - - // If we have response content, that's good enough for the test - if responseContent != "" { - t.Logf("Got response content, considering test successful") - goto done - } - } - } - -done: - // Verify response - if responseContent != "hello world" { - t.Fatalf("unexpected response: %q", responseContent) - } - - // Verify status file exists (even if it's still pending, that's ok for now) - if statusContent == "" { - t.Fatalf("expected status content, got empty") - } - - // Verify API was called - if requestCount != 1 { - t.Fatalf("expected 1 API call, got %d", requestCount) - } - if len(gotReqBody) == 0 { - t.Fatalf("server did not receive request body") - } - - // Note: Status may still show "pending" due to race condition, but that's acceptable - // for this basic test. The important thing is that the response was generated. -} - -func TestGptfsMultipleRequests(t *testing.T) { - // Create temp directory for testing - tempDir, err := os.MkdirTemp("", "gptfs-test-multiple") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - // Use a real directory for mount_path - mountPath := filepath.Join(tempDir, "mount") - if err := os.MkdirAll(mountPath, 0755); err != nil { - t.Fatalf("Failed to create mount dir: %v", err) - } - - // Mock OpenAI server - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"response"}}]}`)) - })) - defer ts.Close() - - // Initialize GPTFS - config := map[string]interface{}{ - "api_host": ts.URL, - "api_key": "test-key", - "mount_path": mountPath, - "workers": 2, - } - - g := NewGptfs() - if err := g.Initialize(config); err != nil { - t.Fatalf("Initialize GPTFS: %v", err) - } - defer g.Shutdown() - - fs := g.GetFileSystem() - - // Write multiple requests simultaneously - requests := []string{"query1.json", "query2.txt", "query3.md"} - for _, req := range requests { - requestPath := filepath.Join("inbox", req) - data := []byte("test content " + req) - if _, err := fs.Write(requestPath, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate); err != nil { - t.Fatalf("write %s: %v", req, err) - } - } - - // Wait for all responses - timeout := time.After(5 * time.Second) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - responses := make(map[string]bool) - - for { - select { - case <-timeout: - t.Fatalf("timeout waiting for responses") - case <-ticker.C: - for _, req := range requests { - if responses[req] { - continue - } - - baseName := req[:len(req)-len(filepath.Ext(req))] - responsePath := filepath.Join("outbox", baseName+"_response.txt") - if response, err := fs.Read(responsePath, 0, -1); err == nil || (err == io.EOF && response != nil) { - responses[req] = true - } - } - - if len(responses) == len(requests) { - goto done - } - } - } - -done: - // Verify all requests have responses - for _, req := range requests { - if !responses[req] { - t.Fatalf("missing response for %s", req) - } - } -} - -func TestGptfsErrorHandling(t *testing.T) { - // Create temp directory for testing - tempDir, err := os.MkdirTemp("", "gptfs-test-error") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - // Use a real directory for mount_path - mountPath := filepath.Join(tempDir, "mount") - if err := os.MkdirAll(mountPath, 0755); err != nil { - t.Fatalf("Failed to create mount dir: %v", err) - } - - // Mock server that returns error - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "server error", http.StatusInternalServerError) - })) - defer ts.Close() - - // Initialize GPTFS - config := map[string]interface{}{ - "api_host": ts.URL, - "api_key": "test-key", - "mount_path": mountPath, - "workers": 1, - } - - g := NewGptfs() - if err := g.Initialize(config); err != nil { - t.Fatalf("Initialize GPTFS: %v", err) - } - defer g.Shutdown() - - fs := g.GetFileSystem() - - // Write request file - requestPath := filepath.Join("inbox", "error_test.json") - data := []byte(`{"test": "error"}`) - - if _, err := fs.Write(requestPath, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate); err != nil { - t.Fatalf("write request.json: %v", err) - } - - // Wait for error status - timeout := time.After(5 * time.Second) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-timeout: - // Fallback: if implementation didn't persist failure status, ensure no response file was created - responsePath := filepath.Join("outbox", "error_test_response.txt") - if _, err := fs.Read(responsePath, 0, -1); err == nil { - t.Fatalf("unexpected response file present despite API error") - } - // Also ensure the initial pending status file exists - if _, err := fs.Read(filepath.Join("outbox", "error_test_status.json"), 0, -1); err != nil && err != io.EOF { - t.Fatalf("expected pending status file to exist: %v", err) - } - goto done - case <-ticker.C: - statusPath := filepath.Join("outbox", "job_status.json") - if statusData, err := fs.Read(statusPath, 0, -1); err == nil { - var status JobRequest - if err := json.Unmarshal(statusData, &status); err == nil { - if status.Status == "failed" { - goto done - } - } - } - } - } - -done: - // If we reached here via timeout fallback, absence of response is our signal of failure. - // Otherwise, if job_status.json existed and was parsed, we already exited earlier. -} - -func TestGptfsValidate(t *testing.T) { - g := NewGptfs() - - // Valid config - validConfig := map[string]interface{}{ - "api_key": "test-key", - "api_host": "http://example.com", - "mount_path": "/tmp", - } - if err := g.Validate(validConfig); err != nil { - t.Fatalf("Validate valid config: %v", err) - } - - // Missing api_key - invalidConfig1 := map[string]interface{}{ - "api_host": "http://example.com", - "mount_path": "/tmp", - } - if err := g.Validate(invalidConfig1); err == nil { - t.Fatalf("expected error for missing api_key") - } - - // Missing api_host - invalidConfig2 := map[string]interface{}{ - "api_key": "test-key", - "mount_path": "/tmp", - } - if err := g.Validate(invalidConfig2); err == nil { - t.Fatalf("expected error for missing api_host") - } - - // Missing mount_path - invalidConfig3 := map[string]interface{}{ - "api_key": "test-key", - "api_host": "http://example.com", - } - if err := g.Validate(invalidConfig3); err == nil { - t.Fatalf("expected error for missing data_dir") - } - - // Unknown keys - invalidConfig4 := map[string]interface{}{ - "api_key": "test-key", - "api_host": "http://example.com", - "mount_path": "/tmp", - "unknown": "key", - } - if err := g.Validate(invalidConfig4); err == nil { - t.Fatalf("expected error for unknown keys") - } -} - -func TestGptfsGetReadme(t *testing.T) { - g := NewGptfs() - readme := g.GetReadme() - - expectedStrings := []string{ - "GPTFS Plugin", - "Async GPT Processing", - "Persistent Storage", - "inbox/", - "outbox/", - "_response.txt", - "_status.json", - "workflow", - "configuration", - } - - for _, expected := range expectedStrings { - if !strings.Contains(strings.ToLower(readme), strings.ToLower(expected)) { - t.Fatalf("readme missing expected string: %q", expected) - } - } -} - -func TestGptfsGetConfigParams(t *testing.T) { - g := NewGptfs() - params := g.GetConfigParams() - - expectedParams := map[string]bool{ - "api_key": false, - "api_host": false, - "data_dir": false, - "workers": false, - } - - if len(params) != len(expectedParams) { - t.Fatalf("expected %d config params, got %d", len(expectedParams), len(params)) - } - - for _, param := range params { - if _, exists := expectedParams[param.Name]; !exists { - t.Fatalf("unexpected config param: %q", param.Name) - } - expectedParams[param.Name] = true - } - - for param, found := range expectedParams { - if !found { - t.Fatalf("missing config param: %q", param) - } - } -} - -func TestGptfsRegularWriteDelegation(t *testing.T) { - // Create temp directory for testing - tempDir, err := os.MkdirTemp("", "gptfs-test-regular") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tempDir) - - mountPath := filepath.Join(tempDir, "mount") - if err := os.MkdirAll(mountPath, 0755); err != nil { - t.Fatalf("Failed to create mount dir: %v", err) - } - - // Initialize GPTFS - config := map[string]interface{}{ - "api_host": "http://127.0.0.1:0", - "api_key": "test-key", - "mount_path": mountPath, - "workers": 1, - } - - g := NewGptfs() - if err := g.Initialize(config); err != nil { - t.Fatalf("Initialize GPTFS: %v", err) - } - defer g.Shutdown() - - fs := g.GetFileSystem() - - // Test regular file operations - testPath := "regular.txt" - testContent := "test content" - - // Write - if _, err := fs.Write(testPath, []byte(testContent), -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate); err != nil { - t.Fatalf("write regular file: %v", err) - } - - // Read - out, err := fs.Read(testPath, 0, -1) - if err != nil && err != io.EOF { - t.Fatalf("read regular file: %v", err) - } - if string(out) != testContent { - t.Fatalf("unexpected content: expected %q, got %q", testContent, string(out)) - } - - // Stat - info, err := fs.Stat(testPath) - if err != nil { - t.Fatalf("stat regular file: %v", err) - } - if info.Size != int64(len(testContent)) { - t.Fatalf("unexpected size: expected %d, got %d", len(testContent), info.Size) - } -} \ No newline at end of file diff --git a/third_party/agfs/agfs-server/pkg/plugins/heartbeatfs/heartbeatfs.go b/third_party/agfs/agfs-server/pkg/plugins/heartbeatfs/heartbeatfs.go deleted file mode 100644 index 03042adc4..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/heartbeatfs/heartbeatfs.go +++ /dev/null @@ -1,773 +0,0 @@ -package heartbeatfs - -import ( - "bytes" - "container/heap" - "fmt" - "io" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" -) - -const ( - PluginName = "heartbeatfs" -) - -// expiryHeapItem represents an item in the expiry priority queue -type expiryHeapItem struct { - name string - expireTime time.Time - index int // index in the heap -} - -// expiryHeap implements heap.Interface for managing expiry times -type expiryHeap []*expiryHeapItem - -func (h expiryHeap) Len() int { return len(h) } -func (h expiryHeap) Less(i, j int) bool { return h[i].expireTime.Before(h[j].expireTime) } -func (h expiryHeap) Swap(i, j int) { - h[i], h[j] = h[j], h[i] - h[i].index = i - h[j].index = j -} - -func (h *expiryHeap) Push(x interface{}) { - n := len(*h) - item := x.(*expiryHeapItem) - item.index = n - *h = append(*h, item) -} - -func (h *expiryHeap) Pop() interface{} { - old := *h - n := len(old) - item := old[n-1] - old[n-1] = nil - item.index = -1 - *h = old[0 : n-1] - return item -} - -// HeartbeatItem represents a heartbeat instance -type HeartbeatItem struct { - name string - lastHeartbeat time.Time - expireTime time.Time - timeout time.Duration // timeout duration for this item - heapItem *expiryHeapItem // reference to heap item for fast update - mu sync.RWMutex -} - -// HeartbeatFSPlugin provides a heartbeat monitoring service through a file system interface -// Each heartbeat item is a directory containing control files -// Operations: -// mkdir /heartbeatfs/<dir> - Create new heartbeat item -// touch /<dir>/keepalive - Update heartbeat timestamp -// echo "data" > /<dir>/keepalive - Update heartbeat timestamp -// cat /<dir>/ctl - Read heartbeat status -type HeartbeatFSPlugin struct { - items map[string]*HeartbeatItem - expiryHeap expiryHeap - mu sync.RWMutex - heapMu sync.Mutex // separate lock for heap operations - metadata plugin.PluginMetadata - stopChan chan struct{} - wg sync.WaitGroup - defaultTimeout time.Duration // default timeout from config -} - -// NewHeartbeatFSPlugin creates a new heartbeat monitoring plugin -func NewHeartbeatFSPlugin() *HeartbeatFSPlugin { - hb := &HeartbeatFSPlugin{ - items: make(map[string]*HeartbeatItem), - expiryHeap: make(expiryHeap, 0), - metadata: plugin.PluginMetadata{ - Name: PluginName, - Version: "1.0.0", - Description: "Heartbeat monitoring service plugin", - Author: "AGFS Server", - }, - stopChan: make(chan struct{}), - defaultTimeout: 5 * time.Minute, // default 5 minutes if not configured - } - heap.Init(&hb.expiryHeap) - return hb -} - -// cleanupExpiredItems runs in background and removes expired heartbeat items -// Uses a min-heap to efficiently track and remove only expired items -func (hb *HeartbeatFSPlugin) cleanupExpiredItems() { - defer hb.wg.Done() - - for { - // Calculate next wake up time based on the earliest expiry - var sleepDuration time.Duration - - hb.heapMu.Lock() - if len(hb.expiryHeap) == 0 { - hb.heapMu.Unlock() - sleepDuration = 1 * time.Second // default check interval when no items - } else { - nextExpiry := hb.expiryHeap[0].expireTime - hb.heapMu.Unlock() - - now := time.Now() - if nextExpiry.After(now) { - sleepDuration = nextExpiry.Sub(now) - // Cap maximum sleep to avoid sleeping too long - if sleepDuration > 1*time.Second { - sleepDuration = 1 * time.Second - } - } else { - sleepDuration = 0 // process immediately - } - } - - // Sleep or wait for stop signal - if sleepDuration > 0 { - select { - case <-hb.stopChan: - return - case <-time.After(sleepDuration): - } - } - - // Process expired items - now := time.Now() - - for { - hb.heapMu.Lock() - if len(hb.expiryHeap) == 0 { - hb.heapMu.Unlock() - break - } - - // Check if the earliest item has expired - earliest := hb.expiryHeap[0] - if earliest.expireTime.After(now) { - hb.heapMu.Unlock() - break - } - - // Remove from heap - heap.Pop(&hb.expiryHeap) - hb.heapMu.Unlock() - - // Remove from items map - hb.mu.Lock() - delete(hb.items, earliest.name) - hb.mu.Unlock() - } - } -} - -func (hb *HeartbeatFSPlugin) Name() string { - return hb.metadata.Name -} - -func (hb *HeartbeatFSPlugin) Validate(cfg map[string]interface{}) error { - allowedKeys := []string{"mount_path", "default_timeout"} - for key := range cfg { - found := false - for _, allowed := range allowedKeys { - if key == allowed { - found = true - break - } - } - if !found { - return fmt.Errorf("unknown configuration parameter: %s (allowed: %v)", key, allowedKeys) - } - } - return nil -} - -func (hb *HeartbeatFSPlugin) Initialize(config map[string]interface{}) error { - // Load default_timeout from config - if timeoutVal, ok := config["default_timeout"]; ok { - switch v := timeoutVal.(type) { - case int: - hb.defaultTimeout = time.Duration(v) * time.Second - case float64: - hb.defaultTimeout = time.Duration(v) * time.Second - case string: - // Try to parse as duration string (e.g., "5m", "300s") - if d, err := time.ParseDuration(v); err == nil { - hb.defaultTimeout = d - } - } - } - - // Start background cleanup goroutine - hb.wg.Add(1) - go hb.cleanupExpiredItems() - return nil -} - -func (hb *HeartbeatFSPlugin) GetFileSystem() filesystem.FileSystem { - return &heartbeatFS{plugin: hb} -} - -func (hb *HeartbeatFSPlugin) GetReadme() string { - return `HeartbeatFS Plugin - Heartbeat Monitoring Service - -This plugin provides a heartbeat monitoring service through a file system interface. - -USAGE: - Create a new heartbeat item: - mkdir /heartbeatfs/<name> - - Update heartbeat (keepalive): - touch /heartbeatfs/<name>/keepalive - echo "ping" > /heartbeatfs/<name>/keepalive - - Update timeout: - echo "timeout=60" > /heartbeatfs/<name>/ctl - - Check heartbeat status: - cat /heartbeatfs/<name>/ctl - - Check if heartbeat is alive (stat will fail if expired): - stat /heartbeatfs/<name> - - List all heartbeat items: - ls /heartbeatfs - - Remove heartbeat item: - rm -r /heartbeatfs/<name> - -STRUCTURE: - /<name>/ - Directory for each heartbeat item (auto-deleted when expired) - /<name>/keepalive - Touch or write to update heartbeat - /<name>/ctl - Read to get status, write to update timeout (timeout=N in seconds) - /README - This file - -BEHAVIOR: - - Default timeout: 5 minutes (300 seconds) from last heartbeat - - Timeout can be customized per item by writing to ctl file - - Expired items are automatically removed by the system - - Use stat to check if an item still exists (alive) - -EXAMPLES: - # Create a heartbeat item - agfs:/> mkdir /heartbeatfs/myservice - - # Send heartbeat - agfs:/> touch /heartbeatfs/myservice/keepalive - - # Set custom timeout (60 seconds) - agfs:/> echo "timeout=60" > /heartbeatfs/myservice/ctl - - # Check status - agfs:/> cat /heartbeatfs/myservice/ctl - last_heartbeat_ts: 2024-11-21T10:30:00Z - expire_ts: 2024-11-21T10:31:00Z - timeout: 60 - status: alive - - # Check if still alive (will fail if expired) - agfs:/> stat /heartbeatfs/myservice -` -} - -func (hb *HeartbeatFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "default_timeout", - Type: "int", - Required: false, - Default: "30", - Description: "Default heartbeat timeout in seconds", - }, - } -} - -func (hb *HeartbeatFSPlugin) Shutdown() error { - // Stop cleanup goroutine - close(hb.stopChan) - hb.wg.Wait() - - hb.mu.Lock() - defer hb.mu.Unlock() - hb.items = nil - return nil -} - -// heartbeatFS implements the FileSystem interface for heartbeat operations -type heartbeatFS struct { - plugin *HeartbeatFSPlugin -} - -func (hfs *heartbeatFS) Create(path string) error { - return fmt.Errorf("use mkdir to create heartbeat items") -} - -func (hfs *heartbeatFS) Mkdir(path string, perm uint32) error { - if path == "/" { - return nil - } - - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) != 1 { - return fmt.Errorf("can only create heartbeat items at root level") - } - - name := parts[0] - if name == "" || name == "README" { - return fmt.Errorf("invalid heartbeat item name: %s", name) - } - - hfs.plugin.mu.Lock() - defer hfs.plugin.mu.Unlock() - - if _, exists := hfs.plugin.items[name]; exists { - return fmt.Errorf("heartbeat item already exists: %s", name) - } - - now := time.Now() - defaultTimeout := hfs.plugin.defaultTimeout - expireTime := now.Add(defaultTimeout) - - // Create heap item - heapItem := &expiryHeapItem{ - name: name, - expireTime: expireTime, - } - - // Create heartbeat item - item := &HeartbeatItem{ - name: name, - lastHeartbeat: now, - timeout: defaultTimeout, - expireTime: expireTime, - heapItem: heapItem, - } - - hfs.plugin.items[name] = item - - // Add to heap - hfs.plugin.heapMu.Lock() - heap.Push(&hfs.plugin.expiryHeap, heapItem) - hfs.plugin.heapMu.Unlock() - - return nil -} - -func (hfs *heartbeatFS) Remove(path string) error { - return hfs.RemoveAll(path) -} - -func (hfs *heartbeatFS) RemoveAll(path string) error { - if path == "/" { - return fmt.Errorf("cannot remove root") - } - - parts := strings.Split(strings.Trim(path, "/"), "/") - name := parts[0] - - hfs.plugin.mu.Lock() - item, exists := hfs.plugin.items[name] - if !exists { - hfs.plugin.mu.Unlock() - return fmt.Errorf("heartbeat item not found: %s", name) - } - delete(hfs.plugin.items, name) - hfs.plugin.mu.Unlock() - - // Remove from heap - hfs.plugin.heapMu.Lock() - if item.heapItem != nil && item.heapItem.index >= 0 { - heap.Remove(&hfs.plugin.expiryHeap, item.heapItem.index) - } - hfs.plugin.heapMu.Unlock() - - return nil -} - -func (hfs *heartbeatFS) Read(path string, offset int64, size int64) ([]byte, error) { - if path == "/" { - return nil, fmt.Errorf("is a directory") - } - - if path == "/README" { - data := []byte(hfs.plugin.GetReadme()) - return plugin.ApplyRangeRead(data, offset, size) - } - - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) != 2 { - return nil, fmt.Errorf("invalid path: %s", path) - } - - name := parts[0] - file := parts[1] - - hfs.plugin.mu.RLock() - item, exists := hfs.plugin.items[name] - hfs.plugin.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("heartbeat item not found: %s", name) - } - - var data []byte - switch file { - case "keepalive": - data = []byte("") - case "ctl": - item.mu.RLock() - now := time.Now() - status := "alive" - if now.After(item.expireTime) { - status = "expired" - } - data = []byte(fmt.Sprintf("last_heartbeat_ts: %s\nexpire_ts: %s\ntimeout: %d\nstatus: %s\n", - item.lastHeartbeat.Format(time.RFC3339), - item.expireTime.Format(time.RFC3339), - int(item.timeout.Seconds()), - status)) - item.mu.RUnlock() - default: - return nil, fmt.Errorf("invalid file: %s", file) - } - - return plugin.ApplyRangeRead(data, offset, size) -} - -func (hfs *heartbeatFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - if path == "/" { - return 0, fmt.Errorf("cannot write to directory") - } - - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) != 2 { - return 0, fmt.Errorf("invalid path: %s", path) - } - - name := parts[0] - file := parts[1] - - hfs.plugin.mu.RLock() - item, exists := hfs.plugin.items[name] - hfs.plugin.mu.RUnlock() - - if !exists { - return 0, fmt.Errorf("heartbeat item not found: %s", name) - } - - now := time.Now() - - switch file { - case "keepalive": - // Update heartbeat timestamp - item.mu.Lock() - item.lastHeartbeat = now - newExpireTime := now.Add(item.timeout) - item.expireTime = newExpireTime - heapItem := item.heapItem - item.mu.Unlock() - - // Update heap - hfs.plugin.heapMu.Lock() - if heapItem != nil && heapItem.index >= 0 { - heapItem.expireTime = newExpireTime - heap.Fix(&hfs.plugin.expiryHeap, heapItem.index) - } - hfs.plugin.heapMu.Unlock() - - case "ctl": - // Parse timeout=N from data - content := strings.TrimSpace(string(data)) - var newTimeout int - _, err := fmt.Sscanf(content, "timeout=%d", &newTimeout) - if err != nil { - return 0, fmt.Errorf("invalid ctl command, use 'timeout=N' (seconds)") - } - if newTimeout <= 0 { - return 0, fmt.Errorf("timeout must be positive") - } - - // Update timeout and recalculate expire time - item.mu.Lock() - item.timeout = time.Duration(newTimeout) * time.Second - // Recalculate expire time based on last heartbeat and new timeout - newExpireTime := item.lastHeartbeat.Add(item.timeout) - item.expireTime = newExpireTime - heapItem := item.heapItem - item.mu.Unlock() - - // Update heap - hfs.plugin.heapMu.Lock() - if heapItem != nil && heapItem.index >= 0 { - heapItem.expireTime = newExpireTime - heap.Fix(&hfs.plugin.expiryHeap, heapItem.index) - } - hfs.plugin.heapMu.Unlock() - - default: - return 0, fmt.Errorf("can only write to keepalive or ctl files") - } - - return int64(len(data)), nil -} - -func (hfs *heartbeatFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - if path == "/" { - hfs.plugin.mu.RLock() - defer hfs.plugin.mu.RUnlock() - - files := make([]filesystem.FileInfo, 0, len(hfs.plugin.items)+1) - - // Add README - readme := hfs.plugin.GetReadme() - files = append(files, filesystem.FileInfo{ - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - }) - - // Add each heartbeat item - for name := range hfs.plugin.items { - files = append(files, filesystem.FileInfo{ - Name: name, - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "heartbeat_dir", - }, - }) - } - - return files, nil - } - - // List files in heartbeat item directory - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) != 1 { - return nil, fmt.Errorf("not a directory: %s", path) - } - - name := parts[0] - hfs.plugin.mu.RLock() - item, exists := hfs.plugin.items[name] - hfs.plugin.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("heartbeat item not found: %s", name) - } - - item.mu.RLock() - defer item.mu.RUnlock() - - return []filesystem.FileInfo{ - { - Name: "keepalive", - Size: 0, - Mode: 0644, - ModTime: item.lastHeartbeat, - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "keepalive", - }, - }, - { - Name: "ctl", - Size: 0, - Mode: 0644, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "control", - }, - }, - }, nil -} - -func (hfs *heartbeatFS) Stat(path string) (*filesystem.FileInfo, error) { - if path == "/" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "root", - }, - }, nil - } - - if path == "/README" { - readme := hfs.plugin.GetReadme() - return &filesystem.FileInfo{ - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - }, nil - } - - parts := strings.Split(strings.Trim(path, "/"), "/") - name := parts[0] - - hfs.plugin.mu.RLock() - item, exists := hfs.plugin.items[name] - hfs.plugin.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("heartbeat item not found: %s", name) - } - - // If requesting the directory itself - if len(parts) == 1 { - return &filesystem.FileInfo{ - Name: name, - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "heartbeat_dir", - }, - }, nil - } - - // If requesting a file in the directory - if len(parts) != 2 { - return nil, fmt.Errorf("invalid path: %s", path) - } - - file := parts[1] - item.mu.RLock() - defer item.mu.RUnlock() - - switch file { - case "keepalive": - return &filesystem.FileInfo{ - Name: "keepalive", - Size: 0, - Mode: 0644, - ModTime: item.lastHeartbeat, - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "keepalive", - }, - }, nil - case "ctl": - return &filesystem.FileInfo{ - Name: "ctl", - Size: 0, - Mode: 0644, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "control", - }, - }, nil - default: - return nil, fmt.Errorf("file not found: %s", file) - } -} - -func (hfs *heartbeatFS) Rename(oldPath, newPath string) error { - return fmt.Errorf("rename not supported in heartbeatfs") -} - -func (hfs *heartbeatFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("chmod not supported in heartbeatfs") -} - -func (hfs *heartbeatFS) Open(path string) (io.ReadCloser, error) { - data, err := hfs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(bytes.NewReader(data)), nil -} - -func (hfs *heartbeatFS) OpenWrite(path string) (io.WriteCloser, error) { - return &heartbeatWriter{hfs: hfs, path: path, buf: &bytes.Buffer{}}, nil -} - -type heartbeatWriter struct { - hfs *heartbeatFS - path string - buf *bytes.Buffer -} - -func (hw *heartbeatWriter) Write(p []byte) (n int, err error) { - return hw.buf.Write(p) -} - -func (hw *heartbeatWriter) Close() error { - _, err := hw.hfs.Write(hw.path, hw.buf.Bytes(), -1, filesystem.WriteFlagNone) - return err -} - -// Touch implements filesystem.Toucher interface -// Efficiently updates timestamp by directly updating heartbeat item -func (hfs *heartbeatFS) Touch(path string) error { - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) != 2 { - return fmt.Errorf("invalid path for touch: %s", path) - } - - name := parts[0] - file := parts[1] - - // Only support touching keepalive file - if file != "keepalive" { - return fmt.Errorf("can only touch keepalive file") - } - - hfs.plugin.mu.RLock() - item, exists := hfs.plugin.items[name] - hfs.plugin.mu.RUnlock() - - if !exists { - return fmt.Errorf("heartbeat item not found: %s", name) - } - - // Update heartbeat timestamp efficiently (no content read/write) - now := time.Now() - item.mu.Lock() - item.lastHeartbeat = now - newExpireTime := now.Add(item.timeout) - item.expireTime = newExpireTime - heapItem := item.heapItem - item.mu.Unlock() - - // Update heap - hfs.plugin.heapMu.Lock() - if heapItem != nil && heapItem.index >= 0 { - heapItem.expireTime = newExpireTime - heap.Fix(&hfs.plugin.expiryHeap, heapItem.index) - } - hfs.plugin.heapMu.Unlock() - - return nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/hellofs/README.md b/third_party/agfs/agfs-server/pkg/plugins/hellofs/README.md deleted file mode 100644 index 9106ffd38..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/hellofs/README.md +++ /dev/null @@ -1,13 +0,0 @@ -HelloFS Plugin - Minimal Demo - -This plugin provides a single file: /hello - -MOUNT: - agfs:/> mount hellofs /hello - -USAGE: - cat /hellofs/hello - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/hellofs/hellofs.go b/third_party/agfs/agfs-server/pkg/plugins/hellofs/hellofs.go deleted file mode 100644 index 40d86a552..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/hellofs/hellofs.go +++ /dev/null @@ -1,151 +0,0 @@ -package hellofs - -import ( - "errors" - "io" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" -) - -const ( - PluginName = "hellofs" -) - -// HelloFSPlugin is a minimal plugin that only provides a single "hello" file -type HelloFSPlugin struct{} - -// NewHelloFSPlugin creates a new HelloFS plugin -func NewHelloFSPlugin() *HelloFSPlugin { - return &HelloFSPlugin{} -} - -func (p *HelloFSPlugin) Name() string { - return PluginName -} - -func (p *HelloFSPlugin) Validate(cfg map[string]interface{}) error { - // Only mount_path is allowed (injected by framework) - allowedKeys := []string{"mount_path"} - return config.ValidateOnlyKnownKeys(cfg, allowedKeys) -} - -func (p *HelloFSPlugin) Initialize(config map[string]interface{}) error { - return nil -} - -func (p *HelloFSPlugin) GetFileSystem() filesystem.FileSystem { - return &HelloFS{} -} - -func (p *HelloFSPlugin) GetReadme() string { - return `HelloFS Plugin - Minimal Demo - -This plugin provides a single file: /hello - -USAGE: - cat /hellofs/hello -` -} - -func (p *HelloFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{} -} - -func (p *HelloFSPlugin) Shutdown() error { - return nil -} - -// HelloFS is a minimal filesystem that only supports reading /hello -type HelloFS struct{} - -func (fs *HelloFS) Read(path string, offset int64, size int64) ([]byte, error) { - if path == "/hello" { - data := []byte("Hello, World!\n") - return plugin.ApplyRangeRead(data, offset, size) - } - return nil, filesystem.ErrNotFound -} - -func (fs *HelloFS) Stat(path string) (*filesystem.FileInfo, error) { - if path == "/hello" { - return &filesystem.FileInfo{ - Name: "hello", - Size: 14, - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "file"}, - }, nil - } - if path == "/" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0555, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "directory"}, - }, nil - } - return nil, filesystem.ErrNotFound -} - -func (fs *HelloFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - if path == "/" { - return []filesystem.FileInfo{ - { - Name: "hello", - Size: 14, - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "file"}, - }, - }, nil - } - return nil, errors.New("not a directory") -} - -// Unsupported operations -func (fs *HelloFS) Create(path string) error { - return errors.New("read-only filesystem") -} - -func (fs *HelloFS) Mkdir(path string, perm uint32) error { - return errors.New("read-only filesystem") -} - -func (fs *HelloFS) Remove(path string) error { - return errors.New("read-only filesystem") -} - -func (fs *HelloFS) RemoveAll(path string) error { - return errors.New("read-only filesystem") -} - -func (fs *HelloFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - return 0, errors.New("read-only filesystem") -} - -func (fs *HelloFS) Rename(oldPath, newPath string) error { - return errors.New("read-only filesystem") -} - -func (fs *HelloFS) Chmod(path string, mode uint32) error { - return errors.New("read-only filesystem") -} - -func (fs *HelloFS) Open(path string) (io.ReadCloser, error) { - return nil, errors.New("not implemented") -} - -func (fs *HelloFS) OpenWrite(path string) (io.WriteCloser, error) { - return nil, errors.New("read-only filesystem") -} - -// Ensure HelloFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*HelloFSPlugin)(nil) -var _ filesystem.FileSystem = (*HelloFS)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugins/httpfs/httpfs.go b/third_party/agfs/agfs-server/pkg/plugins/httpfs/httpfs.go deleted file mode 100644 index e58ded062..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/httpfs/httpfs.go +++ /dev/null @@ -1,873 +0,0 @@ -package httpfs - -import ( - "context" - "fmt" - "html/template" - "io" - "mime" - "net/http" - "path" - "path/filepath" - "sort" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "httpfs" -) - -// getContentType determines the Content-Type based on file extension -func getContentType(filename string) string { - // Get the base filename (without directory) - baseName := filepath.Base(filename) - baseNameUpper := strings.ToUpper(baseName) - - // Special handling for README files (with or without extension) - // These should display as text/plain in the browser - if baseNameUpper == "README" || - strings.HasPrefix(baseNameUpper, "README.") { - return "text/plain; charset=utf-8" - } - - ext := strings.ToLower(filepath.Ext(filename)) - - // Common text formats that should display inline - textTypes := map[string]string{ - ".txt": "text/plain; charset=utf-8", - ".md": "text/markdown; charset=utf-8", - ".markdown": "text/markdown; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".xml": "application/xml; charset=utf-8", - ".html": "text/html; charset=utf-8", - ".htm": "text/html; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".js": "application/javascript; charset=utf-8", - ".yaml": "text/yaml; charset=utf-8", - ".yml": "text/yaml; charset=utf-8", - ".log": "text/plain; charset=utf-8", - ".csv": "text/csv; charset=utf-8", - ".sh": "text/x-shellscript; charset=utf-8", - ".py": "text/x-python; charset=utf-8", - ".go": "text/x-go; charset=utf-8", - ".c": "text/x-c; charset=utf-8", - ".cpp": "text/x-c++; charset=utf-8", - ".h": "text/x-c; charset=utf-8", - ".java": "text/x-java; charset=utf-8", - ".rs": "text/x-rust; charset=utf-8", - ".sql": "text/x-sql; charset=utf-8", - } - - // Image formats - imageTypes := map[string]string{ - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".bmp": "image/bmp", - } - - // Video formats - videoTypes := map[string]string{ - ".mp4": "video/mp4", - ".webm": "video/webm", - ".ogg": "video/ogg", - ".avi": "video/x-msvideo", - ".mov": "video/quicktime", - } - - // Audio formats - audioTypes := map[string]string{ - ".mp3": "audio/mpeg", - ".wav": "audio/wav", - ".ogg": "audio/ogg", - ".m4a": "audio/mp4", - ".flac": "audio/flac", - } - - // PDF - if ext == ".pdf" { - return "application/pdf" - } - - // Check our custom maps first - if ct, ok := textTypes[ext]; ok { - return ct - } - if ct, ok := imageTypes[ext]; ok { - return ct - } - if ct, ok := videoTypes[ext]; ok { - return ct - } - if ct, ok := audioTypes[ext]; ok { - return ct - } - - // Fallback to mime package - if ct := mime.TypeByExtension(ext); ct != "" { - return ct - } - - // Default to octet-stream for unknown types (will trigger download) - return "application/octet-stream" -} - -// HTTPFS implements FileSystem interface with an embedded HTTP server -// It serves files from an AGFS mount path over HTTP like 'python3 -m http.server' -type HTTPFS struct { - agfsPath string // The AGFS path to serve (e.g., "/memfs") - httpHost string // HTTP server host (e.g., "localhost", "0.0.0.0") - httpPort string // HTTP server port - statusPath string // Virtual status file path (e.g., "/httpfs-demo") - rootFS filesystem.FileSystem // Reference to the root AGFS filesystem - mu sync.RWMutex - server *http.Server - pluginName string - startTime time.Time // Server start time -} - -// NewHTTPFS creates a new HTTP file server that serves AGFS paths -func NewHTTPFS(agfsPath string, host string, port string, statusPath string, rootFS filesystem.FileSystem) (*HTTPFS, error) { - if agfsPath == "" { - return nil, fmt.Errorf("agfs_path is required") - } - - if rootFS == nil { - return nil, fmt.Errorf("rootFS is required") - } - - // Normalize paths - agfsPath = filesystem.NormalizePath(agfsPath) - statusPath = filesystem.NormalizePath(statusPath) - - if host == "" { - host = "0.0.0.0" // Default to all interfaces - } - - if port == "" { - port = "8000" // Default port like python http.server - } - - fs := &HTTPFS{ - agfsPath: agfsPath, - httpHost: host, - httpPort: port, - statusPath: statusPath, - rootFS: rootFS, - pluginName: PluginName, - startTime: time.Now(), - } - - // Start HTTP server - if err := fs.startHTTPServer(); err != nil { - return nil, fmt.Errorf("failed to start HTTP server: %w", err) - } - - return fs, nil -} - -// resolveAGFSPath converts a URL path to a AGFS path -func (fs *HTTPFS) resolveAGFSPath(urlPath string) string { - urlPath = filesystem.NormalizePath(urlPath) - if urlPath == "/" { - return fs.agfsPath - } - return path.Join(fs.agfsPath, urlPath) -} - -// startHTTPServer starts the HTTP server -func (fs *HTTPFS) startHTTPServer() error { - mux := http.NewServeMux() - mux.HandleFunc("/", fs.handleHTTPRequest) - - addr := fs.httpHost + ":" + fs.httpPort - fs.server = &http.Server{ - Addr: addr, - Handler: mux, - } - - go func() { - log.Infof("[httpfs] Starting HTTP server on %s, serving AGFS path: %s", addr, fs.agfsPath) - log.Infof("[httpfs] HTTP server listening at http://%s:%s", fs.httpHost, fs.httpPort) - if err := fs.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Errorf("[httpfs] HTTP server error on %s: %v", addr, err) - } else if err == http.ErrServerClosed { - log.Infof("[httpfs] HTTP server on %s closed gracefully", addr) - } - }() - - return nil -} - -// handleHTTPRequest handles HTTP requests -func (fs *HTTPFS) handleHTTPRequest(w http.ResponseWriter, r *http.Request) { - urlPath := r.URL.Path - pfsPath := fs.resolveAGFSPath(urlPath) - - log.Infof("[httpfs:%s] %s %s (AGFS path: %s) from %s", fs.httpPort, r.Method, urlPath, pfsPath, r.RemoteAddr) - - // Get file info - info, err := fs.rootFS.Stat(pfsPath) - if err != nil { - log.Warnf("[httpfs:%s] Not found: %s (AGFS: %s)", fs.httpPort, urlPath, pfsPath) - http.NotFound(w, r) - return - } - - // If it's a directory, list contents - if info.IsDir { - fs.serveDirectory(w, r, pfsPath, urlPath) - return - } - - // Serve file - fs.serveFile(w, r, pfsPath) -} - -// serveFile serves a file -func (fs *HTTPFS) serveFile(w http.ResponseWriter, r *http.Request, pfsPath string) { - // Get file info for headers - info, err := fs.rootFS.Stat(pfsPath) - if err != nil { - http.Error(w, "Failed to stat file", http.StatusInternalServerError) - log.Errorf("[httpfs:%s] Failed to stat file %s: %v", fs.httpPort, pfsPath, err) - return - } - - // Determine content type based on file extension - contentType := getContentType(pfsPath) - log.Infof("[httpfs:%s] Serving file: %s (size: %d bytes, type: %s)", fs.httpPort, pfsPath, info.Size, contentType) - - // Try to open file using Open method - reader, err := fs.rootFS.Open(pfsPath) - if err != nil { - // Fallback: use Read method if Open is not supported - log.Debugf("[httpfs:%s] Open failed for %s, falling back to Read: %v", fs.httpPort, pfsPath, err) - data, err := fs.rootFS.Read(pfsPath, 0, -1) - // EOF is expected when reading the entire file - if err != nil && err != io.EOF { - http.Error(w, "Failed to read file", http.StatusInternalServerError) - log.Errorf("[httpfs:%s] Failed to read file %s: %v", fs.httpPort, pfsPath, err) - return - } - - // Set headers - w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) - w.Header().Set("Last-Modified", info.ModTime.Format(http.TimeFormat)) - - // Write content - w.Write(data) - log.Infof("[httpfs:%s] Sent file: %s (%d bytes via Read)", fs.httpPort, pfsPath, len(data)) - return - } - defer reader.Close() - - // Set headers - w.Header().Set("Content-Type", contentType) - w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size)) - w.Header().Set("Last-Modified", info.ModTime.Format(http.TimeFormat)) - - // Copy content - written, _ := io.Copy(w, reader) - log.Infof("[httpfs:%s] Sent file: %s (%d bytes via stream)", fs.httpPort, pfsPath, written) -} - -// serveDirectory serves a directory listing -func (fs *HTTPFS) serveDirectory(w http.ResponseWriter, r *http.Request, pfsPath string, urlPath string) { - entries, err := fs.rootFS.ReadDir(pfsPath) - if err != nil { - log.Errorf("[httpfs:%s] Failed to read directory %s: %v", fs.httpPort, pfsPath, err) - http.Error(w, "Failed to read directory", http.StatusInternalServerError) - return - } - - log.Infof("[httpfs:%s] Serving directory: %s (%d entries)", fs.httpPort, pfsPath, len(entries)) - - // Sort entries: directories first, then files, alphabetically - sort.Slice(entries, func(i, j int) bool { - if entries[i].IsDir != entries[j].IsDir { - return entries[i].IsDir - } - return entries[i].Name < entries[j].Name - }) - - // Build directory listing - type FileEntry struct { - Name string - IsDir bool - Size int64 - ModTime string - URL string - } - - var files []FileEntry - for _, entry := range entries { - name := entry.Name - url := path.Join(urlPath, name) - if entry.IsDir { - name += "/" - url += "/" - } - - files = append(files, FileEntry{ - Name: name, - IsDir: entry.IsDir, - Size: entry.Size, - ModTime: entry.ModTime.Format("2006-01-02 15:04:05"), - URL: url, - }) - } - - // Render HTML - tmpl := `<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <title>Directory listing for {{.Path}} - - - -

Directory listing for {{.Path}}

-
- {{if .Parent}} -

↑ Parent Directory

- {{end}} - - - - - - - - - - {{range .Files}} - - - - - - {{end}} - -
NameSizeModified
{{.Name}}{{if .IsDir}}-{{else}}{{.Size}}{{end}}{{.ModTime}}
-
-

agfs httagfs server - serving: {{.PFSPath}}

- -` - - t, err := template.New("directory").Parse(tmpl) - if err != nil { - http.Error(w, "Template error", http.StatusInternalServerError) - return - } - - parent := "" - if urlPath != "/" { - // Clean the path to remove trailing slash before getting parent - // This is important because path.Dir("/level1/") returns "/level1" - // but path.Dir("/level1") returns "/" - cleanPath := strings.TrimSuffix(urlPath, "/") - parent = path.Dir(cleanPath) - // Ensure parent path ends with / for proper directory navigation - // But don't add extra / if already at root - if parent != "/" && !strings.HasSuffix(parent, "/") { - parent = parent + "/" - } - } - - data := struct { - Path string - PFSPath string - Parent string - Files []FileEntry - }{ - Path: urlPath, - PFSPath: pfsPath, - Parent: parent, - Files: files, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - t.Execute(w, data) -} - -// FileSystem interface implementation - these are placeholder implementations -// since httagfs doesn't provide its own filesystem, it just serves another AGFS path via HTTP - -func (fs *HTTPFS) Create(path string) error { - return fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Mkdir(path string, perm uint32) error { - return fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Remove(path string) error { - return fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) RemoveAll(path string) error { - return fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Read(path string, offset int64, size int64) ([]byte, error) { - // Check if this is the virtual status file - if path == "/" || path == "" { - // Return status information - statusData := []byte(fs.getStatusInfo()) - - // Handle offset and size - if offset >= int64(len(statusData)) { - return []byte{}, io.EOF - } - - data := statusData[offset:] - if size > 0 && int64(len(data)) > size { - data = data[:size] - } - - return data, nil - } - - return nil, fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - return 0, fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - return nil, fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Stat(path string) (*filesystem.FileInfo, error) { - // Check if this is the virtual status file - if path == "/" || path == "" { - statusData := fs.getStatusInfo() - return &filesystem.FileInfo{ - Name: "status", - Size: int64(len(statusData)), - Mode: 0444, // Read-only - ModTime: fs.startTime, - IsDir: false, - Meta: filesystem.MetaData{ - Name: "httpfs-status", - Type: "virtual", - }, - }, nil - } - - return nil, fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Rename(oldPath, newPath string) error { - return fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) Open(path string) (io.ReadCloser, error) { - return nil, fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -func (fs *HTTPFS) OpenWrite(path string) (io.WriteCloser, error) { - return nil, fmt.Errorf("httagfs is read-only via filesystem interface, use HTTP to access files") -} - -// getStatusInfo returns the status information for this httagfs instance -func (fs *HTTPFS) getStatusInfo() string { - fs.mu.RLock() - defer fs.mu.RUnlock() - - uptime := time.Since(fs.startTime) - - status := fmt.Sprintf(`HTTPFS Instance Status -====================== - -Virtual Path: %s -AGFS Source Path: %s -HTTP Host: %s -HTTP Port: %s -HTTP Endpoint: http://%s:%s - -Server Status: Running -Start Time: %s -Uptime: %s - -Access this HTTP server: - Browser: http://%s:%s/ - CLI: curl http://%s:%s/ - -Serving content from AGFS path: %s -All files under %s are accessible via HTTP on %s:%s -`, - fs.statusPath, - fs.agfsPath, - fs.httpHost, - fs.httpPort, - fs.httpHost, - fs.httpPort, - fs.startTime.Format("2006-01-02 15:04:05"), - uptime.Round(time.Second).String(), - fs.httpHost, - fs.httpPort, - fs.httpHost, - fs.httpPort, - fs.agfsPath, - fs.agfsPath, - fs.httpHost, - fs.httpPort, - ) - - return status -} - -// Shutdown stops the HTTP server -func (fs *HTTPFS) Shutdown() error { - if fs.server != nil { - log.Infof("[httpfs:%s] Shutting down HTTP server...", fs.httpPort) - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - err := fs.server.Shutdown(ctx) - if err != nil { - log.Errorf("[httpfs:%s] Error during shutdown: %v", fs.httpPort, err) - } else { - log.Infof("[httpfs:%s] HTTP server shutdown complete", fs.httpPort) - } - return err - } - return nil -} - -// HTTPFSPlugin wraps HTTPFS as a plugin -type HTTPFSPlugin struct { - fs *HTTPFS - agfsPath string - httpHost string - httpPort string - statusPath string - rootFS filesystem.FileSystem -} - -// NewHTTPFSPlugin creates a new HTTPFS plugin -func NewHTTPFSPlugin() *HTTPFSPlugin { - return &HTTPFSPlugin{} -} - -func (p *HTTPFSPlugin) Name() string { - return PluginName -} - -func (p *HTTPFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"agfs_path", "host", "port", "mount_path"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate agfs_path (required) - if _, err := config.RequireString(cfg, "agfs_path"); err != nil { - return err - } - - // Validate optional string parameters - for _, key := range []string{"host", "mount_path"} { - if err := config.ValidateStringType(cfg, key); err != nil { - return err - } - } - - // Validate port - can be string, int, or float64 - if val, exists := cfg["port"]; exists { - switch val.(type) { - case string, int, int64, float64: - // Valid types - default: - return fmt.Errorf("port must be a string or number") - } - } - - return nil -} - -// SetRootFS sets the root filesystem reference -func (p *HTTPFSPlugin) SetRootFS(rootFS filesystem.FileSystem) { - p.rootFS = rootFS -} - -func (p *HTTPFSPlugin) Initialize(config map[string]interface{}) error { - // Parse configuration - pfsPath, ok := config["agfs_path"].(string) - if !ok || pfsPath == "" { - return fmt.Errorf("agfs_path is required in configuration") - } - - p.agfsPath = pfsPath - - // Get HTTP host (optional, defaults to 0.0.0.0) - httpHost := "0.0.0.0" - if host, ok := config["host"].(string); ok && host != "" { - httpHost = host - } - p.httpHost = httpHost - - // Get HTTP port (optional, defaults to 8000) - // Support both string, integer, and float64 (JSON numbers) port values - httpPort := "8000" - if port, ok := config["port"].(string); ok && port != "" { - httpPort = port - } else if portInt, ok := config["port"].(int); ok { - httpPort = fmt.Sprintf("%d", portInt) - } else if portFloat, ok := config["port"].(float64); ok { - httpPort = fmt.Sprintf("%d", int(portFloat)) - } - p.httpPort = httpPort - - // Get mount path (virtual status path) - statusPath := "/" - if mountPath, ok := config["mount_path"].(string); ok && mountPath != "" { - statusPath = mountPath - } - p.statusPath = statusPath - - // Create HTTPFS instance if rootFS is available - if p.rootFS != nil { - fs, err := NewHTTPFS(p.agfsPath, p.httpHost, p.httpPort, p.statusPath, p.rootFS) - if err != nil { - return fmt.Errorf("failed to initialize httpfs: %w", err) - } - p.fs = fs - log.Infof("[httpfs] Initialized with AGFS path: %s, HTTP server: http://%s:%s, Status path: %s", pfsPath, httpHost, httpPort, statusPath) - } else { - log.Infof("[httpfs] Configured to serve AGFS path: %s on HTTP %s:%s (will start after rootFS is available)", pfsPath, httpHost, httpPort) - } - - return nil -} - -func (p *HTTPFSPlugin) GetFileSystem() filesystem.FileSystem { - // Lazy initialization: create HTTPFS instance if not already created - if p.fs == nil && p.rootFS != nil { - fs, err := NewHTTPFS(p.agfsPath, p.httpHost, p.httpPort, p.statusPath, p.rootFS) - if err != nil { - log.Errorf("[httpfs] Failed to initialize: %v", err) - return nil - } - p.fs = fs - } - return p.fs -} - -func (p *HTTPFSPlugin) GetReadme() string { - readmeContent := fmt.Sprintf(`HTTPFS Plugin - HTTP File Server for AGFS Paths - -This plugin serves a AGFS mount path over HTTP, similar to 'python3 -m http.server'. -Unlike serving local files, this exposes any AGFS filesystem (memfs, queuefs, s3fs, etc.) via HTTP. - -FEATURES: - - Serve any AGFS path via HTTP (e.g., /memfs, /queuefs, /s3fs) - - Browse files and directories in web browser - - Download files via HTTP - - Pretty HTML directory listings - - Access AGFS virtual filesystems through HTTP - - Read-only HTTP access (modifications should be done through AGFS API) - - Support for dynamic mounting via AGFS Shell mount command - -CONFIGURATION: - - Basic configuration: - [plugins.httpfs] - enabled = true - path = "/httpfs" # This is just a placeholder, not used for serving - - [plugins.httpfs.config] - agfs_path = "/memfs" # The AGFS path to serve (e.g., /memfs, /queuefs) - host = "0.0.0.0" # Optional, defaults to 0.0.0.0 (all interfaces) - port = "8000" # Optional, defaults to 8000 - - Example - Serve memfs: - [plugins.httpfs_mem] - enabled = true - path = "/httpfs_mem" - - [plugins.httpfs_mem.config] - agfs_path = "/memfs" - host = "localhost" - port = "9000" - - Example - Serve queuefs: - [plugins.httpfs_queue] - enabled = true - path = "/httpfs_queue" - - [plugins.httpfs_queue.config] - agfs_path = "/queuefs" - port = "9001" - -CURRENT CONFIGURATION: - AGFS Path: %s - HTTP Server: http://%s:%s - -DYNAMIC MOUNTING: - - You can dynamically mount httagfs at runtime using AGFS Shell: - - # In AGFS Shell REPL: - > mount httagfs /httpfs-demo agfs_path=/memfs port=10000 - plugin mounted - - > mount httagfs /web agfs_path=/local host=localhost port=9000 - plugin mounted - - > mounts - httagfs on /httpfs-demo (plugin: httpfs, agfs_path=/memfs, port=10000) - httagfs on /web (plugin: httpfs, agfs_path=/local, host=localhost, port=9000) - - > unmount /httpfs-demo - Unmounted plugin at /httpfs-demo - - # Via command line: - agfs mount httagfs /httpfs-demo agfs_path=/memfs port=10000 - agfs mount httagfs /web agfs_path=/local host=localhost port=9000 - agfs unmount /httpfs-demo - - # Via REST API: - curl -X POST http://localhost:8080/api/v1/mount \ - -H "Content-Type: application/json" \ - -d '{ - "fstype": "httpfs", - "path": "/httpfs-demo", - "config": { - "agfs_path": "/memfs", - "host": "0.0.0.0", - "port": "10000" - } - }' - - Dynamic mounting advantages: - - No server restart required - - Mount/unmount on demand - - Multiple instances with different configurations - - Flexible port and path selection - -USAGE: - - Via Web Browser: - Open: http://localhost:%s - Browse directories and download files from AGFS - - Via curl: - # List directory - curl http://localhost:%s/ - - # Download file - curl http://localhost:%s/file.txt - - # Access subdirectory - curl http://localhost:%s/subdir/ - -EXAMPLES: - - # Serve memfs on port 9000 - http://localhost:9000 -> shows contents of /memfs - - # Serve queuefs on port 9001 - http://localhost:9001 -> shows contents of /queuefs - - # Access files in browser - Open http://localhost:%s in your browser - Click on files to download - Click on directories to browse - -NOTES: - - The HTTP server starts automatically when the plugin is initialized - - Files are served with proper MIME types - - Directory listings are formatted as pretty HTML - - httagfs provides HTTP read-only access to AGFS paths - - To modify files, use the AGFS API directly - - Multiple httagfs instances can serve different AGFS paths on different ports - -USE CASES: - - Expose in-memory files (memfs) via HTTP for easy access - - Browse queue contents (queuefs) in a web browser - - Share S3 files (s3fs) through a simple HTTP interface - - Provide web access to any AGFS filesystem - - Quick file sharing without setting up separate web servers - - Debug and inspect AGFS filesystems visually - -ADVANTAGES: - - Works with any AGFS filesystem (not just local files) - - Simple HTTP interface for complex backends - - Multiple instances can serve different paths - - No data duplication - serves directly from AGFS - - Lightweight and fast - -VERSION: 1.0.0 -AUTHOR: AGFS Server -`, p.agfsPath, p.httpHost, p.httpPort, p.httpPort, p.httpPort, p.httpPort, p.httpPort, p.httpPort) - - return readmeContent -} - -func (p *HTTPFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "agfs_path", - Type: "string", - Required: true, - Default: "", - Description: "AGFS path to serve over HTTP (e.g., /memfs, /queuefs)", - }, - { - Name: "host", - Type: "string", - Required: false, - Default: "0.0.0.0", - Description: "HTTP server host address", - }, - { - Name: "port", - Type: "string", - Required: false, - Default: "8000", - Description: "HTTP server port", - }, - } -} - -func (p *HTTPFSPlugin) Shutdown() error { - log.Infof("[httpfs] Plugin shutting down (port: %s, path: %s)", p.httpPort, p.agfsPath) - if p.fs != nil { - return p.fs.Shutdown() - } - return nil -} - -// Ensure HTTPFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*HTTPFSPlugin)(nil) -var _ filesystem.FileSystem = (*HTTPFS)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugins/kvfs/README.md b/third_party/agfs/agfs-server/pkg/plugins/kvfs/README.md deleted file mode 100644 index 7211ba0e1..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/kvfs/README.md +++ /dev/null @@ -1,62 +0,0 @@ -KVFS Plugin - Key-Value Store Service - -This plugin provides a key-value store service through a file system interface. - -DYNAMIC MOUNTING WITH AGFS SHELL: - - Interactive shell: - agfs:/> mount kvfs /kv - agfs:/> mount kvfs /cache - - Direct command: - uv run agfs mount kvfs /kv - uv run agfs mount kvfs /store - -CONFIGURATION PARAMETERS: - - Optional: - - initial_data: Map of initial key-value pairs to populate on mount - - Example with initial data: - agfs:/> mount kvfs /config initial_data='{"app":"myapp","version":"1.0"}' - -USAGE: - Set a key-value pair: - echo "value" > /keys/ - - Get a value: - cat /keys/ - - List all keys: - ls /keys - - Delete a key: - rm /keys/ - - Rename a key: - mv /keys/ /keys/ - -STRUCTURE: - /keys/ - Directory containing all key-value pairs - /README - This file - -EXAMPLES: - # Set a value - agfs:/> echo "hello world" > /kvfs/keys/mykey - - # Get a value - agfs:/> cat /kvfs/keys/mykey - hello world - - # List all keys - agfs:/> ls /kvfs/keys - - # Delete a key - agfs:/> rm /kvfs/keys/mykey - - # Rename a key - agfs:/> mv /kvfs/keys/oldname /kvfs/keys/newname - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/kvfs/kvfs.go b/third_party/agfs/agfs-server/pkg/plugins/kvfs/kvfs.go deleted file mode 100644 index da4baa00d..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/kvfs/kvfs.go +++ /dev/null @@ -1,452 +0,0 @@ -package kvfs - -import ( - "bytes" - "fmt" - "io" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" -) - -const ( - PluginName = "kvfs" // Name of this plugin -) - -// Meta values for KVFS plugin -const ( - MetaValueDir = "dir" // KV store directory - MetaValueFile = "file" // KV store data file -) - -// KVFSPlugin provides a key-value store service through a file system interface -// Each key is represented as a file, and the file content is the value -// Operations: -// -// GET /keys/ - Read value -// PUT /keys/ - Write value -// DELETE /keys/ - Delete key -// GET /keys - List all keys -type KVFSPlugin struct { - store map[string][]byte - mu sync.RWMutex - metadata plugin.PluginMetadata -} - -// NewKVFSPlugin creates a new key-value store plugin -func NewKVFSPlugin() *KVFSPlugin { - return &KVFSPlugin{ - store: make(map[string][]byte), - metadata: plugin.PluginMetadata{ - Name: PluginName, - Version: "1.0.0", - Description: "Key-Value store service plugin", - Author: "VFS Server", - }, - } -} - -func (kv *KVFSPlugin) Name() string { - return kv.metadata.Name -} - -func (kv *KVFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"initial_data", "mount_path"} - for key := range cfg { - found := false - for _, allowed := range allowedKeys { - if key == allowed { - found = true - break - } - } - if !found { - return fmt.Errorf("unknown configuration parameter: %s (allowed: %v)", key, allowedKeys) - } - } - - // Validate initial_data if provided - if val, exists := cfg["initial_data"]; exists { - // Check if it's a map[string]interface{} or map[string]string - switch val.(type) { - case map[string]interface{}, map[string]string: - // Valid types - default: - return fmt.Errorf("initial_data must be a map/object") - } - } - return nil -} - -func (kv *KVFSPlugin) Initialize(config map[string]interface{}) error { - // Load initial data if provided - if data, ok := config["initial_data"].(map[string]string); ok { - for k, v := range data { - kv.store[k] = []byte(v) - } - } - return nil -} - -func (kv *KVFSPlugin) GetFileSystem() filesystem.FileSystem { - return &kvFS{plugin: kv} -} - -func (kv *KVFSPlugin) GetReadme() string { - return `KVFS Plugin - Key-Value Store Service - -This plugin provides a key-value store service through a file system interface. - -USAGE: - Set a key-value pair: - echo "value" > /keys/ - - Get a value: - cat /keys/ - - List all keys: - ls /keys - - Delete a key: - rm /keys/ - - Rename a key: - mv /keys/ /keys/ - -STRUCTURE: - /keys/ - Directory containing all key-value pairs - /README - This file - -EXAMPLES: - # Set a value - agfs:/> echo "hello world" > /kvfs/keys/mykey - - # Get a value - agfs:/> cat /kvfs/keys/mykey - hello world - - # List all keys - agfs:/> ls /kvfs/keys - - # Delete a key - agfs:/> rm /kvfs/keys/mykey - - # Rename a key - agfs:/> mv /kvfs/keys/oldname /kvfs/keys/newname -` -} - -func (kv *KVFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{} -} - -func (kv *KVFSPlugin) Shutdown() error { - kv.mu.Lock() - defer kv.mu.Unlock() - kv.store = nil - return nil -} - -// kvFS implements the FileSystem interface for key-value operations -type kvFS struct { - plugin *KVFSPlugin -} - -func (kvfs *kvFS) Create(path string) error { - if path == "/" || path == "/keys" { - return fmt.Errorf("cannot create: %s", path) - } - - // Only allow creating files under /keys/ - if !strings.HasPrefix(path, "/keys/") { - return fmt.Errorf("keys must be under /keys/ directory") - } - - key := strings.TrimPrefix(path, "/keys/") - if key == "" { - return fmt.Errorf("key name cannot be empty") - } - - kvfs.plugin.mu.Lock() - defer kvfs.plugin.mu.Unlock() - - if _, exists := kvfs.plugin.store[key]; exists { - return fmt.Errorf("key already exists: %s", key) - } - - kvfs.plugin.store[key] = []byte{} - return nil -} - -func (kvfs *kvFS) Mkdir(path string, perm uint32) error { - if path == "/keys" { - return nil // /keys directory always exists - } - return fmt.Errorf("cannot create directories in kvfs service") -} - -func (kvfs *kvFS) Remove(path string) error { - if !strings.HasPrefix(path, "/keys/") { - return fmt.Errorf("can only remove keys under /keys/") - } - - key := strings.TrimPrefix(path, "/keys/") - if key == "" { - return fmt.Errorf("key name cannot be empty") - } - - kvfs.plugin.mu.Lock() - defer kvfs.plugin.mu.Unlock() - - if _, exists := kvfs.plugin.store[key]; !exists { - return fmt.Errorf("key not found: %s", key) - } - - delete(kvfs.plugin.store, key) - return nil -} - -func (kvfs *kvFS) RemoveAll(path string) error { - if path == "/keys" { - // Clear all keys - kvfs.plugin.mu.Lock() - defer kvfs.plugin.mu.Unlock() - kvfs.plugin.store = make(map[string][]byte) - return nil - } - return kvfs.Remove(path) -} - -func (kvfs *kvFS) Read(path string, offset int64, size int64) ([]byte, error) { - if path == "/" || path == "/keys" { - return nil, fmt.Errorf("is a directory: %s", path) - } - - var data []byte - if path == "/README" { - data = []byte(kvfs.plugin.GetReadme()) - } else if strings.HasPrefix(path, "/keys/") { - key := strings.TrimPrefix(path, "/keys/") - if key == "" { - return nil, fmt.Errorf("key name cannot be empty") - } - - kvfs.plugin.mu.RLock() - value, exists := kvfs.plugin.store[key] - kvfs.plugin.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("key not found: %s", key) - } - data = value - } else { - return nil, fmt.Errorf("invalid path: %s", path) - } - - return plugin.ApplyRangeRead(data, offset, size) -} - -func (kvfs *kvFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - if path == "/" || path == "/keys" { - return 0, fmt.Errorf("cannot write to directory: %s", path) - } - - if !strings.HasPrefix(path, "/keys/") { - return 0, fmt.Errorf("keys must be under /keys/ directory") - } - - key := strings.TrimPrefix(path, "/keys/") - if key == "" { - return 0, fmt.Errorf("key name cannot be empty") - } - - kvfs.plugin.mu.Lock() - defer kvfs.plugin.mu.Unlock() - - // KV store - offset writes not supported (full value replacement) - kvfs.plugin.store[key] = data - return int64(len(data)), nil -} - -func (kvfs *kvFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - if path == "/" { - // Root directory contains /keys and README - readme := kvfs.plugin.GetReadme() - return []filesystem.FileInfo{ - { - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - }, - { - Name: "keys", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: MetaValueDir, - }, - }, - }, nil - } - - if path == "/keys" { - // List all keys - kvfs.plugin.mu.RLock() - defer kvfs.plugin.mu.RUnlock() - - files := make([]filesystem.FileInfo, 0, len(kvfs.plugin.store)) - for key, value := range kvfs.plugin.store { - files = append(files, filesystem.FileInfo{ - Name: filepath.Base(key), - Size: int64(len(value)), - Mode: 0644, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: MetaValueFile, - }, - }) - } - - return files, nil - } - - return nil, fmt.Errorf("not a directory: %s", path) -} - -func (kvfs *kvFS) Stat(path string) (*filesystem.FileInfo, error) { - if path == "/" || path == "/keys" { - return &filesystem.FileInfo{ - Name: filepath.Base(path), - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: MetaValueDir, - }, - }, nil - } - - if path == "/README" { - readme := kvfs.plugin.GetReadme() - return &filesystem.FileInfo{ - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - }, nil - } - - if !strings.HasPrefix(path, "/keys/") { - return nil, fmt.Errorf("invalid path: %s", path) - } - - key := strings.TrimPrefix(path, "/keys/") - if key == "" { - return nil, fmt.Errorf("key name cannot be empty") - } - - kvfs.plugin.mu.RLock() - defer kvfs.plugin.mu.RUnlock() - - value, exists := kvfs.plugin.store[key] - if !exists { - return nil, fmt.Errorf("key not found: %s", key) - } - - return &filesystem.FileInfo{ - Name: filepath.Base(key), - Size: int64(len(value)), - Mode: 0644, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: MetaValueFile, - }, - }, nil -} - -func (kvfs *kvFS) Rename(oldPath, newPath string) error { - if !strings.HasPrefix(oldPath, "/keys/") || !strings.HasPrefix(newPath, "/keys/") { - return fmt.Errorf("can only rename keys under /keys/") - } - - oldKey := strings.TrimPrefix(oldPath, "/keys/") - newKey := strings.TrimPrefix(newPath, "/keys/") - - if oldKey == "" || newKey == "" { - return fmt.Errorf("key name cannot be empty") - } - - kvfs.plugin.mu.Lock() - defer kvfs.plugin.mu.Unlock() - - value, exists := kvfs.plugin.store[oldKey] - if !exists { - return fmt.Errorf("key not found: %s", oldKey) - } - - if _, exists := kvfs.plugin.store[newKey]; exists { - return fmt.Errorf("key already exists: %s", newKey) - } - - kvfs.plugin.store[newKey] = value - delete(kvfs.plugin.store, oldKey) - - return nil -} - -func (kvfs *kvFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("cannot change permissions in kvfs service") -} - -func (kvfs *kvFS) Open(path string) (io.ReadCloser, error) { - data, err := kvfs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(bytes.NewReader(data)), nil -} - -func (kvfs *kvFS) OpenWrite(path string) (io.WriteCloser, error) { - return &kvWriter{kvfs: kvfs, path: path, buf: &bytes.Buffer{}}, nil -} - -type kvWriter struct { - kvfs *kvFS - path string - buf *bytes.Buffer -} - -func (kw *kvWriter) Write(p []byte) (n int, err error) { - return kw.buf.Write(p) -} - -func (kw *kvWriter) Close() error { - _, err := kw.kvfs.Write(kw.path, kw.buf.Bytes(), -1, filesystem.WriteFlagNone) - return err -} - diff --git a/third_party/agfs/agfs-server/pkg/plugins/localfs/localfs.go b/third_party/agfs/agfs-server/pkg/plugins/localfs/localfs.go deleted file mode 100644 index 1facb7bf9..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/localfs/localfs.go +++ /dev/null @@ -1,786 +0,0 @@ -package localfs - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - pluginConfig "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "localfs" -) - -// LocalFS implements FileSystem interface using local file system as backend -type LocalFS struct { - basePath string // The local directory to mount - mu sync.RWMutex - pluginName string -} - -// NewLocalFS creates a new local file system -func NewLocalFS(basePath string) (*LocalFS, error) { - // Resolve to absolute path - absPath, err := filepath.Abs(basePath) - if err != nil { - return nil, fmt.Errorf("failed to resolve base path: %w", err) - } - - // Check if base path exists - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("base path does not exist: %s", absPath) - } - return nil, fmt.Errorf("failed to stat base path: %w", err) - } - - if !info.IsDir() { - return nil, fmt.Errorf("base path is not a directory: %s", absPath) - } - - return &LocalFS{ - basePath: absPath, - pluginName: PluginName, - }, nil -} - -// resolvePath resolves a virtual path to the actual local path -func (fs *LocalFS) resolvePath(path string) string { - // Remove leading slash to make it relative (VFS paths always start with /) - relativePath := strings.TrimPrefix(path, "/") - - // Convert to OS-specific path separators - // On Windows, this converts "/" to "\" - relativePath = filepath.FromSlash(relativePath) - - // Clean the path - relativePath = filepath.Clean(relativePath) - - if relativePath == "." { - return fs.basePath - } - return filepath.Join(fs.basePath, relativePath) -} - -func (fs *LocalFS) ResolvePath(path string) string { - return fs.resolvePath(path) -} - -func (fs *LocalFS) Create(path string) error { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if file already exists - if _, err := os.Stat(localPath); err == nil { - return fmt.Errorf("file already exists: %s", path) - } - - // Check if parent directory exists - parentDir := filepath.Dir(localPath) - if _, err := os.Stat(parentDir); os.IsNotExist(err) { - return fmt.Errorf("parent directory does not exist: %s", filepath.Dir(path)) - } - - // Create empty file - f, err := os.Create(localPath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - f.Close() - - return nil -} - -func (fs *LocalFS) Mkdir(path string, perm uint32) error { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if directory already exists - if _, err := os.Stat(localPath); err == nil { - return fmt.Errorf("directory already exists: %s", path) - } - - // Check if parent directory exists - parentDir := filepath.Dir(localPath) - if _, err := os.Stat(parentDir); os.IsNotExist(err) { - return fmt.Errorf("parent directory does not exist: %s", filepath.Dir(path)) - } - - // Create directory - err := os.Mkdir(localPath, os.FileMode(perm)) - if err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - return nil -} - -func (fs *LocalFS) Remove(path string) error { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if exists - info, err := os.Stat(localPath) - if err != nil { - if os.IsNotExist(err) { - return filesystem.NewNotFoundError("remove", path) - } - return fmt.Errorf("failed to stat: %w", err) - } - - // If directory, check if empty - if info.IsDir() { - entries, err := os.ReadDir(localPath) - if err != nil { - return fmt.Errorf("failed to read directory: %w", err) - } - if len(entries) > 0 { - return fmt.Errorf("directory not empty: %s", path) - } - } - - // Remove file or empty directory - err = os.Remove(localPath) - if err != nil { - return fmt.Errorf("failed to remove: %w", err) - } - - return nil -} - -func (fs *LocalFS) RemoveAll(path string) error { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if exists - if _, err := os.Stat(localPath); os.IsNotExist(err) { - return filesystem.NewNotFoundError("remove", path) - } - - // Remove recursively - err := os.RemoveAll(localPath) - if err != nil { - return fmt.Errorf("failed to remove: %w", err) - } - - return nil -} - -func (fs *LocalFS) Read(path string, offset int64, size int64) ([]byte, error) { - localPath := fs.resolvePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Check if exists and is not a directory - info, err := os.Stat(localPath) - if err != nil { - if os.IsNotExist(err) { - return nil, filesystem.NewNotFoundError("read", path) - } - return nil, fmt.Errorf("failed to stat: %w", err) - } - - if info.IsDir() { - return nil, fmt.Errorf("is a directory: %s", path) - } - - // Open file - f, err := os.Open(localPath) - if err != nil { - return nil, fmt.Errorf("failed to open file: %w", err) - } - defer f.Close() - - // Get file size - fileSize := info.Size() - - // Handle offset - if offset < 0 { - offset = 0 - } - if offset >= fileSize { - return []byte{}, io.EOF - } - - // Seek to offset - _, err = f.Seek(offset, 0) - if err != nil { - return nil, fmt.Errorf("failed to seek: %w", err) - } - - // Determine read size - readSize := size - if size < 0 || offset+size > fileSize { - readSize = fileSize - offset - } - - // Read data - data := make([]byte, readSize) - n, err := io.ReadFull(f, data) - if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { - return nil, fmt.Errorf("failed to read: %w", err) - } - - // Check if we reached end of file - if offset+int64(n) >= fileSize { - return data[:n], io.EOF - } - - return data[:n], nil -} - -func (fs *LocalFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if it's a directory - if info, err := os.Stat(localPath); err == nil && info.IsDir() { - return 0, fmt.Errorf("is a directory: %s", path) - } - - // Check if parent directory exists - parentDir := filepath.Dir(localPath) - if _, err := os.Stat(parentDir); os.IsNotExist(err) { - return 0, fmt.Errorf("parent directory does not exist: %s", filepath.Dir(path)) - } - - // Build open flags - openFlags := os.O_WRONLY - if flags&filesystem.WriteFlagCreate != 0 { - openFlags |= os.O_CREATE - } - if flags&filesystem.WriteFlagExclusive != 0 { - openFlags |= os.O_EXCL - } - if flags&filesystem.WriteFlagTruncate != 0 { - openFlags |= os.O_TRUNC - } - if flags&filesystem.WriteFlagAppend != 0 { - openFlags |= os.O_APPEND - } - - // Default behavior: create and truncate (like the old implementation) - if flags == filesystem.WriteFlagNone && offset < 0 { - openFlags |= os.O_CREATE | os.O_TRUNC - } - - f, err := os.OpenFile(localPath, openFlags, 0644) - if err != nil { - return 0, fmt.Errorf("failed to open file: %w", err) - } - defer f.Close() - - var n int - if offset >= 0 && flags&filesystem.WriteFlagAppend == 0 { - // pwrite: write at specific offset - n, err = f.WriteAt(data, offset) - } else { - // Normal write or append - n, err = f.Write(data) - } - - if err != nil { - return 0, fmt.Errorf("failed to write: %w", err) - } - - if flags&filesystem.WriteFlagSync != 0 { - f.Sync() - } - - return int64(n), nil -} - -func (fs *LocalFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - localPath := fs.resolvePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Check if directory exists - info, err := os.Stat(localPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("no such directory: %s", path) - } - return nil, fmt.Errorf("failed to stat: %w", err) - } - - if !info.IsDir() { - return nil, fmt.Errorf("not a directory: %s", path) - } - - // Read directory - entries, err := os.ReadDir(localPath) - if err != nil { - return nil, fmt.Errorf("failed to read directory: %w", err) - } - - var files []filesystem.FileInfo - for _, entry := range entries { - entryInfo, err := entry.Info() - if err != nil { - continue - } - - files = append(files, filesystem.FileInfo{ - Name: entry.Name(), - Size: entryInfo.Size(), - Mode: uint32(entryInfo.Mode()), - ModTime: entryInfo.ModTime(), - IsDir: entry.IsDir(), - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "local", - }, - }) - } - - return files, nil -} - -func (fs *LocalFS) Stat(path string) (*filesystem.FileInfo, error) { - localPath := fs.resolvePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Get file info - info, err := os.Stat(localPath) - if err != nil { - if os.IsNotExist(err) { - return nil, filesystem.NewNotFoundError("stat", path) - } - return nil, fmt.Errorf("failed to stat: %w", err) - } - - return &filesystem.FileInfo{ - Name: info.Name(), - Size: info.Size(), - Mode: uint32(info.Mode()), - ModTime: info.ModTime(), - IsDir: info.IsDir(), - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "local", - Content: map[string]string{ - "local_path": localPath, - }, - }, - }, nil -} - -func (fs *LocalFS) Rename(oldPath, newPath string) error { - oldLocalPath := fs.resolvePath(oldPath) - newLocalPath := fs.resolvePath(newPath) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if old path exists - if _, err := os.Stat(oldLocalPath); os.IsNotExist(err) { - return filesystem.NewNotFoundError("rename", oldPath) - } - - // Check if new path parent directory exists - newParentDir := filepath.Dir(newLocalPath) - if _, err := os.Stat(newParentDir); os.IsNotExist(err) { - return fmt.Errorf("parent directory does not exist: %s", filepath.Dir(newPath)) - } - - // Rename/move - err := os.Rename(oldLocalPath, newLocalPath) - if err != nil { - return fmt.Errorf("failed to rename: %w", err) - } - - return nil -} - -func (fs *LocalFS) Chmod(path string, mode uint32) error { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if exists - if _, err := os.Stat(localPath); os.IsNotExist(err) { - return filesystem.NewNotFoundError("chmod", path) - } - - // Change permissions - err := os.Chmod(localPath, os.FileMode(mode)) - if err != nil { - return fmt.Errorf("failed to chmod: %w", err) - } - - return nil -} - -func (fs *LocalFS) Open(path string) (io.ReadCloser, error) { - localPath := fs.resolvePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Open file - f, err := os.Open(localPath) - if err != nil { - if os.IsNotExist(err) { - return nil, filesystem.NewNotFoundError("open", path) - } - return nil, fmt.Errorf("failed to open file: %w", err) - } - - return f, nil -} - -func (fs *LocalFS) OpenWrite(path string) (io.WriteCloser, error) { - localPath := fs.resolvePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if parent directory exists - parentDir := filepath.Dir(localPath) - if _, err := os.Stat(parentDir); os.IsNotExist(err) { - return nil, fmt.Errorf("parent directory does not exist: %s", filepath.Dir(path)) - } - - // Open file for writing (create if not exists, truncate if exists) - f, err := os.OpenFile(localPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) - if err != nil { - return nil, fmt.Errorf("failed to open file for writing: %w", err) - } - - return f, nil -} - -// localFSStreamReader implements filesystem.StreamReader for local files -type localFSStreamReader struct { - file *os.File - chunkSize int64 - eof bool - mu sync.Mutex -} - -// ReadChunk reads the next chunk of data with a timeout -func (r *localFSStreamReader) ReadChunk(timeout time.Duration) ([]byte, bool, error) { - r.mu.Lock() - defer r.mu.Unlock() - - // If already reached EOF, return immediately - if r.eof { - return nil, true, io.EOF - } - - // Create a channel for the read operation - type readResult struct { - data []byte - n int - err error - } - resultChan := make(chan readResult, 1) - - // Perform the read in a goroutine - go func() { - buf := make([]byte, r.chunkSize) - n, err := r.file.Read(buf) - resultChan <- readResult{data: buf, n: n, err: err} - }() - - // Wait for either the read to complete or timeout - select { - case result := <-resultChan: - if result.err != nil { - if result.err == io.EOF { - r.eof = true - // If we read some data before EOF, return it - if result.n > 0 { - return result.data[:result.n], false, nil - } - return nil, true, io.EOF - } - return nil, false, result.err - } - - // Check if this is the last chunk (partial read might indicate EOF) - if result.n < int(r.chunkSize) { - r.eof = true - } - - return result.data[:result.n], r.eof, nil - - case <-time.After(timeout): - // Note: the goroutine will continue reading and will be cleaned up when the file is closed - return nil, false, fmt.Errorf("read timeout") - } -} - -// Close closes the file reader -func (r *localFSStreamReader) Close() error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.file != nil { - return r.file.Close() - } - return nil -} - -// OpenStream implements the Streamer interface for streaming file reads -func (fs *LocalFS) OpenStream(path string) (filesystem.StreamReader, error) { - localPath := fs.resolvePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Check if file exists and is not a directory - info, err := os.Stat(localPath) - if err != nil { - if os.IsNotExist(err) { - return nil, filesystem.NewNotFoundError("grep", path) - } - return nil, fmt.Errorf("failed to stat: %w", err) - } - - if info.IsDir() { - return nil, fmt.Errorf("is a directory: %s", path) - } - - // Open file for reading - f, err := os.Open(localPath) - if err != nil { - return nil, fmt.Errorf("failed to open file: %w", err) - } - - log.Infof("[localfs] Opened stream for file: %s (size: %d bytes)", path, info.Size()) - - // Create and return stream reader with 64KB chunk size (matching streamfs default) - return &localFSStreamReader{ - file: f, - chunkSize: 64 * 1024, // 64KB chunks - eof: false, - }, nil -} - -// LocalFSPlugin wraps LocalFS as a plugin -type LocalFSPlugin struct { - fs *LocalFS - basePath string -} - -// NewLocalFSPlugin creates a new LocalFS plugin -func NewLocalFSPlugin() *LocalFSPlugin { - return &LocalFSPlugin{} -} - -func (p *LocalFSPlugin) Name() string { - return PluginName -} - -func (p *LocalFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"local_dir", "mount_path"} - if err := pluginConfig.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate local_dir parameter - basePath, ok := cfg["local_dir"].(string) - if !ok || basePath == "" { - return fmt.Errorf("local_dir is required in configuration") - } - - // Resolve to absolute path - absPath, err := filepath.Abs(basePath) - if err != nil { - return fmt.Errorf("failed to resolve base path: %w", err) - } - - // Check if path exists - info, err := os.Stat(absPath) - if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("base path does not exist: %s", absPath) - } - return fmt.Errorf("failed to stat base path: %w", err) - } - - // Verify it's a directory - if !info.IsDir() { - return fmt.Errorf("base path is not a directory: %s", absPath) - } - - return nil -} - -func (p *LocalFSPlugin) Initialize(config map[string]interface{}) error { - // Parse configuration (validation already done in Validate) - basePath := config["local_dir"].(string) - p.basePath = basePath - - // Create LocalFS instance - fs, err := NewLocalFS(basePath) - if err != nil { - return fmt.Errorf("failed to initialize localfs: %w", err) - } - p.fs = fs - - log.Infof("[localfs] Initialized with base path: %s", basePath) - return nil -} - -func (p *LocalFSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *LocalFSPlugin) GetReadme() string { - readmeContent := fmt.Sprintf(`LocalFS Plugin - Local File System Mount - -This plugin mounts a local directory into the AGFS virtual file system. - -FEATURES: - - Mount any local directory into AGFS - - Full POSIX file system operations - - Direct access to local files and directories - - Preserves file permissions and timestamps - - Efficient file operations (no copying) - -CONFIGURATION: - - Basic configuration: - [plugins.localfs] - enabled = true - path = "/local" - - [plugins.localfs.config] - local_dir = "/path/to/local/directory" - - Multiple local mounts: - [plugins.localfs_home] - enabled = true - path = "/home" - - [plugins.localfs_home.config] - local_dir = "/Users/username" - - [plugins.localfs_data] - enabled = true - path = "/data" - - [plugins.localfs_data.config] - local_dir = "/var/data" - -CURRENT MOUNT: - Base Path: %s - -USAGE: - - List directory: - agfs ls /local - - Read a file: - agfs cat /local/file.txt - - Write to a file: - agfs write /local/file.txt "Hello, World!" - - Create a directory: - agfs mkdir /local/newdir - - Remove a file: - agfs rm /local/file.txt - - Remove directory recursively: - agfs rm -r /local/olddir - - Move/rename: - agfs mv /local/old.txt /local/new.txt - - Change permissions: - agfs chmod 755 /local/script.sh - -EXAMPLES: - - # Basic file operations - agfs:/> ls /local - file1.txt dir1/ dir2/ - - agfs:/> cat /local/file1.txt - Hello from local filesystem! - - agfs:/> echo "new content" > /local/file2.txt - Written 12 bytes to /local/file2.txt - - # Directory operations - agfs:/> mkdir /local/newdir - agfs:/> ls /local - file1.txt file2.txt dir1/ dir2/ newdir/ - -NOTES: - - Changes are directly applied to the local file system - - File permissions are preserved and can be modified - - Symlinks are followed by default - - Be careful with rm -r as it permanently deletes files - -USE CASES: - - Access local configuration files - - Process local data files - - Integrate with existing file-based workflows - - Development and testing with local data - - Backup and sync operations - -ADVANTAGES: - - No data copying overhead - - Direct access to local files - - Preserves all file system metadata - - Supports all standard file operations - - Efficient for large files - -VERSION: 1.0.0 -AUTHOR: AGFS Server -`, p.basePath) - - return readmeContent -} - -func (p *LocalFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "local_dir", - Type: "string", - Required: true, - Default: "", - Description: "Local directory path to expose (must exist)", - }, - } -} - -func (p *LocalFSPlugin) Shutdown() error { - log.Infof("[localfs] Shutting down") - return nil -} - -// Ensure LocalFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*LocalFSPlugin)(nil) -var _ filesystem.FileSystem = (*LocalFS)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugins/localfs/localfs_test.go b/third_party/agfs/agfs-server/pkg/plugins/localfs/localfs_test.go deleted file mode 100644 index 9e331d0f8..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/localfs/localfs_test.go +++ /dev/null @@ -1,564 +0,0 @@ -package localfs - -import ( - "bytes" - "io" - "os" - "path/filepath" - "testing" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -// readIgnoreEOF reads file content, ignoring io.EOF which is expected at end of file -func readIgnoreEOF(fs *LocalFS, path string) ([]byte, error) { - content, err := fs.Read(path, 0, -1) - if err == io.EOF { - return content, nil - } - return content, err -} - -func setupTestDir(t *testing.T) (string, func()) { - t.Helper() - dir, err := os.MkdirTemp("", "localfs-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - return dir, func() { - os.RemoveAll(dir) - } -} - -func newTestFS(t *testing.T, dir string) *LocalFS { - t.Helper() - fs, err := NewLocalFS(dir) - if err != nil { - t.Fatalf("NewLocalFS failed: %v", err) - } - return fs -} - -func TestLocalFSCreate(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create a file - err := fs.Create("/test.txt") - if err != nil { - t.Fatalf("Create failed: %v", err) - } - - // Verify file exists - info, err := fs.Stat("/test.txt") - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - if info.IsDir { - t.Error("Expected file, got directory") - } - if info.Size != 0 { - t.Errorf("Expected size 0, got %d", info.Size) - } - - // Verify on disk - _, err = os.Stat(filepath.Join(dir, "test.txt")) - if err != nil { - t.Fatalf("File not created on disk: %v", err) - } -} - -func TestLocalFSWriteBasic(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - // Write with create flag - data := []byte("Hello, World!") - n, err := fs.Write(path, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - if n != int64(len(data)) { - t.Errorf("Write returned %d, want %d", n, len(data)) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if !bytes.Equal(content, data) { - t.Errorf("Read content mismatch: got %q, want %q", content, data) - } -} - -func TestLocalFSWriteWithOffset(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello, World!"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Write at offset (pwrite-style) - _, err = fs.Write(path, []byte("XXXXX"), 7, filesystem.WriteFlagNone) - if err != nil { - t.Fatalf("Write at offset failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - expected := "Hello, XXXXX!" - if string(content) != expected { - t.Errorf("Content mismatch: got %q, want %q", string(content), expected) - } -} - -func TestLocalFSWriteExtend(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Write at offset beyond file size (should extend with zeros) - _, err = fs.Write(path, []byte("World"), 10, filesystem.WriteFlagNone) - if err != nil { - t.Fatalf("Write at extended offset failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if len(content) != 15 { - t.Errorf("Expected length 15, got %d", len(content)) - } - // Check beginning and end - if string(content[:5]) != "Hello" { - t.Errorf("Beginning mismatch: got %q", string(content[:5])) - } - if string(content[10:]) != "World" { - t.Errorf("End mismatch: got %q", string(content[10:])) - } - // Middle should be zeros - for i := 5; i < 10; i++ { - if content[i] != 0 { - t.Errorf("Expected zero at position %d, got %d", i, content[i]) - } - } -} - -func TestLocalFSWriteAppend(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Append data - _, err = fs.Write(path, []byte(", World!"), 0, filesystem.WriteFlagAppend) - if err != nil { - t.Fatalf("Append failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - expected := "Hello, World!" - if string(content) != expected { - t.Errorf("Content mismatch: got %q, want %q", string(content), expected) - } -} - -func TestLocalFSWriteTruncate(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello, World!"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Write with truncate - _, err = fs.Write(path, []byte("Hi"), -1, filesystem.WriteFlagTruncate) - if err != nil { - t.Fatalf("Truncate write failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if string(content) != "Hi" { - t.Errorf("Content mismatch: got %q, want %q", string(content), "Hi") - } -} - -func TestLocalFSWriteCreateExclusive(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - // Create new file with exclusive flag - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate|filesystem.WriteFlagExclusive) - if err != nil { - t.Fatalf("Exclusive create failed: %v", err) - } - - // Second exclusive create should fail - _, err = fs.Write(path, []byte("World"), -1, filesystem.WriteFlagCreate|filesystem.WriteFlagExclusive) - if err == nil { - t.Error("Expected error for exclusive create on existing file") - } -} - -func TestLocalFSWriteNonExistent(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/nonexistent.txt" - - // Write to non-existent file with offset (no default create behavior) should fail - // Note: LocalFS has backward compatibility: flags==None && offset<0 auto-creates - _, err := fs.Write(path, []byte("Hello"), 0, filesystem.WriteFlagNone) - if err == nil { - t.Error("Expected error for writing to non-existent file without create flag") - } - - // Write with create flag should succeed - _, err = fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write with create flag failed: %v", err) - } -} - -func TestLocalFSReadWithOffset(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - path := "/test.txt" - - data := []byte("Hello, World!") - _, err := fs.Write(path, data, -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Read from offset - content, err := fs.Read(path, 7, 5) - if err != nil && err != io.EOF { - t.Fatalf("Read with offset failed: %v", err) - } - if string(content) != "World" { - t.Errorf("Read content mismatch: got %q, want %q", string(content), "World") - } - - // Read all from offset - content, err = fs.Read(path, 7, -1) - if err != nil && err != io.EOF { - t.Fatalf("Read all from offset failed: %v", err) - } - if string(content) != "World!" { - t.Errorf("Read content mismatch: got %q, want %q", string(content), "World!") - } -} - -func TestLocalFSMkdir(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create directory - err := fs.Mkdir("/testdir", 0755) - if err != nil { - t.Fatalf("Mkdir failed: %v", err) - } - - // Verify directory exists - info, err := fs.Stat("/testdir") - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - if !info.IsDir { - t.Error("Expected directory, got file") - } - - // Verify on disk - diskInfo, err := os.Stat(filepath.Join(dir, "testdir")) - if err != nil { - t.Fatalf("Directory not created on disk: %v", err) - } - if !diskInfo.IsDir() { - t.Error("Disk entry is not a directory") - } -} - -func TestLocalFSRemove(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create and remove file - err := fs.Create("/test.txt") - if err != nil { - t.Fatalf("Create failed: %v", err) - } - - err = fs.Remove("/test.txt") - if err != nil { - t.Fatalf("Remove failed: %v", err) - } - - // Verify file is removed - _, err = fs.Stat("/test.txt") - if err == nil { - t.Error("Expected error for removed file") - } - - // Verify on disk - _, err = os.Stat(filepath.Join(dir, "test.txt")) - if !os.IsNotExist(err) { - t.Error("File should not exist on disk") - } -} - -func TestLocalFSRename(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create file - data := []byte("Hello, World!") - _, err := fs.Write("/old.txt", data, -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Rename - err = fs.Rename("/old.txt", "/new.txt") - if err != nil { - t.Fatalf("Rename failed: %v", err) - } - - // Verify old path doesn't exist - _, err = fs.Stat("/old.txt") - if err == nil { - t.Error("Old path should not exist") - } - - // Verify new path exists with same content - content, err := fs.Read("/new.txt", 0, -1) - if err != nil && err != io.EOF { - t.Fatalf("Read new path failed: %v", err) - } - if !bytes.Equal(content, data) { - t.Errorf("Content mismatch after rename") - } -} - -func TestLocalFSReadDir(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create some files and directories - fs.Mkdir("/dir1", 0755) - fs.Create("/file1.txt") - fs.Create("/file2.txt") - - // Read root directory - infos, err := fs.ReadDir("/") - if err != nil { - t.Fatalf("ReadDir failed: %v", err) - } - - if len(infos) != 3 { - t.Errorf("Expected 3 entries, got %d", len(infos)) - } - - // Verify entries - names := make(map[string]bool) - for _, info := range infos { - names[info.Name] = true - } - - if !names["dir1"] || !names["file1.txt"] || !names["file2.txt"] { - t.Errorf("Missing expected entries: %v", names) - } -} - -func TestLocalFSChmod(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create file - fs.Create("/test.txt") - - // Change mode - err := fs.Chmod("/test.txt", 0600) - if err != nil { - t.Fatalf("Chmod failed: %v", err) - } - - // Verify mode on disk - diskInfo, err := os.Stat(filepath.Join(dir, "test.txt")) - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - // Only check user permission bits (platform differences) - if diskInfo.Mode().Perm()&0700 != 0600 { - t.Errorf("Mode mismatch: got %o", diskInfo.Mode().Perm()) - } -} - -// Note: Truncate, WriteAt, Sync, GetCapabilities, and Touch are optional extension interfaces -// LocalFS may or may not implement them. These tests are skipped for now. - -func TestLocalFSOpenWrite(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create file - fs.Create("/test.txt") - - // Open for writing - w, err := fs.OpenWrite("/test.txt") - if err != nil { - t.Fatalf("OpenWrite failed: %v", err) - } - - // Write through the writer - data := []byte("Hello, World!") - n, err := w.Write(data) - if err != nil { - t.Fatalf("Writer.Write failed: %v", err) - } - if n != len(data) { - t.Errorf("Write returned %d, want %d", n, len(data)) - } - - // Close the writer - err = w.Close() - if err != nil { - t.Fatalf("Writer.Close failed: %v", err) - } - - // Verify content - content, err := fs.Read("/test.txt", 0, -1) - if err != nil && err != io.EOF { - t.Fatalf("Read failed: %v", err) - } - if !bytes.Equal(content, data) { - t.Errorf("Content mismatch: got %q, want %q", content, data) - } -} - -func TestLocalFSOpen(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create file with content - data := []byte("Hello, World!") - _, err := fs.Write("/test.txt", data, -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Open for reading - r, err := fs.Open("/test.txt") - if err != nil { - t.Fatalf("Open failed: %v", err) - } - - // Read through the reader - buf := make([]byte, 100) - n, err := r.Read(buf) - if err != nil { - t.Fatalf("Reader.Read failed: %v", err) - } - if n != len(data) { - t.Errorf("Read returned %d, want %d", n, len(data)) - } - if !bytes.Equal(buf[:n], data) { - t.Errorf("Content mismatch: got %q, want %q", buf[:n], data) - } - - // Close - err = r.Close() - if err != nil { - t.Fatalf("Reader.Close failed: %v", err) - } -} - -func TestLocalFSRemoveAll(t *testing.T) { - dir, cleanup := setupTestDir(t) - defer cleanup() - - fs := newTestFS(t, dir) - - // Create nested structure - fs.Mkdir("/testdir", 0755) - fs.Mkdir("/testdir/subdir", 0755) - fs.Create("/testdir/file1.txt") - fs.Create("/testdir/subdir/file2.txt") - - // RemoveAll - err := fs.RemoveAll("/testdir") - if err != nil { - t.Fatalf("RemoveAll failed: %v", err) - } - - // Verify removed - _, err = fs.Stat("/testdir") - if err == nil { - t.Error("Directory should be removed") - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/memfs/README.md b/third_party/agfs/agfs-server/pkg/plugins/memfs/README.md deleted file mode 100644 index dd2473e34..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/memfs/README.md +++ /dev/null @@ -1,70 +0,0 @@ -MemFS Plugin - In-Memory File System - -This plugin provides a full-featured in-memory file system. - -DYNAMIC MOUNTING WITH AGFS SHELL: - - Interactive shell: - agfs:/> mount memfs /mem - agfs:/> mount memfs /tmp - agfs:/> mount memfs /scratch init_dirs='["/home","/tmp","/data"]' - - Direct command: - uv run agfs mount memfs /mem - uv run agfs mount memfs /tmp init_dirs='["/work","/cache"]' - -CONFIGURATION PARAMETERS: - - Optional: - - init_dirs: Array of directories to create automatically on mount - - Examples: - agfs:/> mount memfs /workspace init_dirs='["/projects","/builds","/logs"]' - -FEATURES: - - Standard file system operations (create, read, write, delete) - - Directory support with hierarchical structure - - File permissions (chmod) - - File/directory renaming and moving - - Metadata tracking - -USAGE: - Create a file: - touch /path/to/file - - Write to a file: - echo "content" > /path/to/file - - Read a file: - cat /path/to/file - - Create a directory: - mkdir /path/to/dir - - List directory: - ls /path/to/dir - - Remove file/directory: - rm /path/to/file - rm -r /path/to/dir - - Move/rename: - mv /old/path /new/path - - Change permissions: - chmod 755 /path/to/file - -EXAMPLES: - agfs:/> mkdir /memfs/data - agfs:/> echo "hello" > /memfs/data/file.txt - agfs:/> cat /memfs/data/file.txt - hello - agfs:/> ls /memfs/data - agfs:/> mv /memfs/data/file.txt /memfs/data/renamed.txt - -VERSION: 1.0.0 -AUTHOR: VFS Server - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/memfs/memfs.go b/third_party/agfs/agfs-server/pkg/plugins/memfs/memfs.go deleted file mode 100644 index 15a0dd498..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/memfs/memfs.go +++ /dev/null @@ -1,133 +0,0 @@ -package memfs - -import ( - "fmt" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" -) - -const ( - PluginName = "memfs" // Name of this plugin -) - -// MemFSPlugin wraps MemoryFS as a plugin -type MemFSPlugin struct { - fs *MemoryFS -} - -// NewMemFSPlugin creates a new MemFS plugin -func NewMemFSPlugin() *MemFSPlugin { - return &MemFSPlugin{ - fs: NewMemoryFSWithPlugin(PluginName), - } -} - -func (p *MemFSPlugin) Name() string { - return PluginName -} - -func (p *MemFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"init_dirs", "mount_path"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate init_dirs if provided - if val, exists := cfg["init_dirs"]; exists { - // Check if it's a slice - if _, ok := val.([]interface{}); !ok { - // Also check for []string type - if _, ok := val.([]string); !ok { - return fmt.Errorf("init_dirs must be an array") - } - } - } - return nil -} - -func (p *MemFSPlugin) Initialize(config map[string]interface{}) error { - // Create README file - readme := []byte(p.GetReadme()) - _ = p.fs.Create("/README") - _, _ = p.fs.Write("/README", readme, -1, filesystem.WriteFlagTruncate) - _ = p.fs.Chmod("/README", 0444) // Make it read-only - - // Initialize with some default directories if needed - if config != nil { - if initDirs, ok := config["init_dirs"].([]string); ok { - for _, dir := range initDirs { - _ = p.fs.Mkdir(dir, 0755) - } - } - } - return nil -} - -func (p *MemFSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *MemFSPlugin) GetReadme() string { - return `MemFS Plugin - In-Memory File System - -This plugin provides a full-featured in-memory file system. - -FEATURES: - - Standard file system operations (create, read, write, delete) - - Directory support with hierarchical structure - - File permissions (chmod) - - File/directory renaming and moving - - Metadata tracking - -USAGE: - Create a file: - touch /path/to/file - - Write to a file: - echo "content" > /path/to/file - - Read a file: - cat /path/to/file - - Create a directory: - mkdir /path/to/dir - - List directory: - ls /path/to/dir - - Remove file/directory: - rm /path/to/file - rm -r /path/to/dir - - Move/rename: - mv /old/path /new/path - - Change permissions: - chmod 755 /path/to/file - -EXAMPLES: - agfs:/> mkdir /memfs/data - agfs:/> echo "hello" > /memfs/data/file.txt - agfs:/> cat /memfs/data/file.txt - hello - agfs:/> ls /memfs/data - agfs:/> mv /memfs/data/file.txt /memfs/data/renamed.txt - -VERSION: 1.0.0 -AUTHOR: VFS Server -` -} - -func (p *MemFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{} -} - -func (p *MemFSPlugin) Shutdown() error { - return nil -} - -// Ensure MemFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*MemFSPlugin)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugins/memfs/memoryfs.go b/third_party/agfs/agfs-server/pkg/plugins/memfs/memoryfs.go deleted file mode 100644 index 79328422a..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/memfs/memoryfs.go +++ /dev/null @@ -1,800 +0,0 @@ -package memfs - -import ( - "bytes" - "fmt" - "io" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" -) - -// Meta values for MemFS plugin -const ( - MetaValueDir = "dir" - MetaValueFile = "file" -) - -// Node represents a file or directory in memory -type Node struct { - Name string - IsDir bool - Data []byte - Mode uint32 - ModTime time.Time - Children map[string]*Node -} - -// MemoryFS implements FileSystem and HandleFS interfaces with in-memory storage -type MemoryFS struct { - root *Node - mu sync.RWMutex - pluginName string - - // Handle management - handles map[int64]*MemoryFileHandle - handlesMu sync.RWMutex - nextHandleID int64 -} - -// NewMemoryFS creates a new in-memory file system -func NewMemoryFS() *MemoryFS { - return NewMemoryFSWithPlugin("") -} - -// NewMemoryFSWithPlugin creates a new in-memory file system with a plugin name -func NewMemoryFSWithPlugin(pluginName string) *MemoryFS { - return &MemoryFS{ - root: &Node{ - Name: "/", - IsDir: true, - Mode: 0755, - ModTime: time.Now(), - Children: make(map[string]*Node), - }, - pluginName: pluginName, - handles: make(map[int64]*MemoryFileHandle), - nextHandleID: 1, - } -} - -// getNode retrieves a node from the tree -func (mfs *MemoryFS) getNode(path string) (*Node, error) { - path = filesystem.NormalizePath(path) - - if path == "/" { - return mfs.root, nil - } - - parts := strings.Split(strings.Trim(path, "/"), "/") - current := mfs.root - - for _, part := range parts { - if !current.IsDir { - return nil, fmt.Errorf("not a directory: %s", path) - } - next, exists := current.Children[part] - if !exists { - return nil, filesystem.NewNotFoundError("getNode", path) - } - current = next - } - - return current, nil -} - -// getParentNode retrieves the parent node and the basename -func (mfs *MemoryFS) getParentNode(path string) (*Node, string, error) { - path = filesystem.NormalizePath(path) - - if path == "/" { - return nil, "", fmt.Errorf("cannot get parent of root") - } - - dir := filepath.Dir(path) - base := filepath.Base(path) - - parent, err := mfs.getNode(dir) - if err != nil { - return nil, "", err - } - - if !parent.IsDir { - return nil, "", fmt.Errorf("parent is not a directory") - } - - return parent, base, nil -} - -// Create creates a new file -func (mfs *MemoryFS) Create(path string) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - parent, name, err := mfs.getParentNode(path) - if err != nil { - return err - } - - if _, exists := parent.Children[name]; exists { - return fmt.Errorf("file already exists: %s", path) - } - - parent.Children[name] = &Node{ - Name: name, - IsDir: false, - Data: []byte{}, - Mode: 0644, - ModTime: time.Now(), - Children: nil, - } - - return nil -} - -// Mkdir creates a new directory -func (mfs *MemoryFS) Mkdir(path string, perm uint32) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - parent, name, err := mfs.getParentNode(path) - if err != nil { - return err - } - - if _, exists := parent.Children[name]; exists { - return fmt.Errorf("directory already exists: %s", path) - } - - parent.Children[name] = &Node{ - Name: name, - IsDir: true, - Mode: perm, - ModTime: time.Now(), - Children: make(map[string]*Node), - } - - return nil -} - -// Remove removes a file or empty directory -func (mfs *MemoryFS) Remove(path string) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - if filesystem.NormalizePath(path) == "/" { - return fmt.Errorf("cannot remove root directory") - } - - parent, name, err := mfs.getParentNode(path) - if err != nil { - return err - } - - node, exists := parent.Children[name] - if !exists { - return filesystem.NewNotFoundError("remove", path) - } - - if node.IsDir && len(node.Children) > 0 { - return fmt.Errorf("directory not empty: %s", path) - } - - delete(parent.Children, name) - return nil -} - -// RemoveAll removes a path and any children it contains -func (mfs *MemoryFS) RemoveAll(path string) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - // If path is root, remove all children but not the root itself - if filesystem.NormalizePath(path) == "/" { - mfs.root.Children = make(map[string]*Node) - return nil - } - - parent, name, err := mfs.getParentNode(path) - if err != nil { - return err - } - - if _, exists := parent.Children[name]; !exists { - return filesystem.NewNotFoundError("remove", path) - } - - delete(parent.Children, name) - return nil -} - -// Read reads file content with optional offset and size -func (mfs *MemoryFS) Read(path string, offset int64, size int64) ([]byte, error) { - mfs.mu.RLock() - defer mfs.mu.RUnlock() - - node, err := mfs.getNode(path) - if err != nil { - return nil, err - } - - if node.IsDir { - return nil, fmt.Errorf("is a directory: %s", path) - } - - return plugin.ApplyRangeRead(node.Data, offset, size) -} - -// Write writes data to a file with optional offset and flags -func (mfs *MemoryFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - parent, name, err := mfs.getParentNode(path) - if err != nil { - if flags&filesystem.WriteFlagCreate == 0 { - return 0, err - } - // Try to get parent again - maybe it doesn't exist - return 0, err - } - - node, exists := parent.Children[name] - - // Handle exclusive flag - if exists && flags&filesystem.WriteFlagExclusive != 0 { - return 0, fmt.Errorf("file already exists: %s", path) - } - - if !exists { - if flags&filesystem.WriteFlagCreate == 0 { - return 0, fmt.Errorf("file not found: %s", path) - } - // Create the file - node = &Node{ - Name: name, - IsDir: false, - Data: []byte{}, - Mode: 0644, - ModTime: time.Now(), - Children: nil, - } - parent.Children[name] = node - } - - if node.IsDir { - return 0, fmt.Errorf("is a directory: %s", path) - } - - // Handle truncate flag - if flags&filesystem.WriteFlagTruncate != 0 { - node.Data = []byte{} - } - - // Handle append flag - if flags&filesystem.WriteFlagAppend != 0 { - offset = int64(len(node.Data)) - } - - // Handle offset write - if offset < 0 { - // Overwrite mode (default): replace entire content - node.Data = data - } else { - // Offset write mode - newSize := offset + int64(len(data)) - if newSize > int64(len(node.Data)) { - newData := make([]byte, newSize) - copy(newData, node.Data) - node.Data = newData - } - copy(node.Data[offset:], data) - } - - node.ModTime = time.Now() - - return int64(len(data)), nil -} - -// ReadDir lists the contents of a directory -func (mfs *MemoryFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - mfs.mu.RLock() - defer mfs.mu.RUnlock() - - node, err := mfs.getNode(path) - if err != nil { - return nil, err - } - - if !node.IsDir { - return nil, fmt.Errorf("not a directory: %s", path) - } - - var infos []filesystem.FileInfo - for _, child := range node.Children { - metaType := MetaValueFile - if child.IsDir { - metaType = MetaValueDir - } - - infos = append(infos, filesystem.FileInfo{ - Name: child.Name, - Size: int64(len(child.Data)), - Mode: child.Mode, - ModTime: child.ModTime, - IsDir: child.IsDir, - Meta: filesystem.MetaData{ - Name: mfs.pluginName, - Type: metaType, - }, - }) - } - - return infos, nil -} - -// Stat returns file information -func (mfs *MemoryFS) Stat(path string) (*filesystem.FileInfo, error) { - mfs.mu.RLock() - defer mfs.mu.RUnlock() - - node, err := mfs.getNode(path) - if err != nil { - return nil, err - } - - metaType := MetaValueFile - if node.IsDir { - metaType = MetaValueDir - } - - return &filesystem.FileInfo{ - Name: node.Name, - Size: int64(len(node.Data)), - Mode: node.Mode, - ModTime: node.ModTime, - IsDir: node.IsDir, - Meta: filesystem.MetaData{ - Name: mfs.pluginName, - Type: metaType, - }, - }, nil -} - -// Rename renames/moves a file or directory -func (mfs *MemoryFS) Rename(oldPath, newPath string) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - oldParent, oldName, err := mfs.getParentNode(oldPath) - if err != nil { - return err - } - - node, exists := oldParent.Children[oldName] - if !exists { - return filesystem.NewNotFoundError("rename", oldPath) - } - - newParent, newName, err := mfs.getParentNode(newPath) - if err != nil { - return err - } - - if _, exists := newParent.Children[newName]; exists { - return fmt.Errorf("file already exists: %s", newPath) - } - - // Move the node - delete(oldParent.Children, oldName) - node.Name = newName - newParent.Children[newName] = node - - return nil -} - -// Chmod changes file permissions -func (mfs *MemoryFS) Chmod(path string, mode uint32) error { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - node, err := mfs.getNode(path) - if err != nil { - return err - } - - node.Mode = mode - return nil -} - -// memoryReadCloser wraps a bytes.Reader to implement io.ReadCloser -type memoryReadCloser struct { - *bytes.Reader -} - -func (m *memoryReadCloser) Close() error { - return nil -} - -// Open opens a file for reading -func (mfs *MemoryFS) Open(path string) (io.ReadCloser, error) { - data, err := mfs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return &memoryReadCloser{bytes.NewReader(data)}, nil -} - -// memoryWriteCloser implements io.WriteCloser for in-memory files -type memoryWriteCloser struct { - buffer *bytes.Buffer - mfs *MemoryFS - path string -} - -func (m *memoryWriteCloser) Write(p []byte) (n int, err error) { - return m.buffer.Write(p) -} - -func (m *memoryWriteCloser) Close() error { - _, err := m.mfs.Write(m.path, m.buffer.Bytes(), -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - return err -} - -// OpenWrite opens a file for writing -func (mfs *MemoryFS) OpenWrite(path string) (io.WriteCloser, error) { - return &memoryWriteCloser{ - buffer: &bytes.Buffer{}, - mfs: mfs, - path: path, - }, nil -} - -// ============================================================================ -// HandleFS Implementation -// ============================================================================ - -// MemoryFileHandle implements FileHandle for in-memory files -type MemoryFileHandle struct { - id int64 - path string - flags filesystem.OpenFlag - mfs *MemoryFS - pos int64 - closed bool - mu sync.Mutex -} - -// ID returns the unique identifier of this handle -func (h *MemoryFileHandle) ID() int64 { - return h.id -} - -// Path returns the file path this handle is associated with -func (h *MemoryFileHandle) Path() string { - return h.path -} - -// Flags returns the open flags used when opening this handle -func (h *MemoryFileHandle) Flags() filesystem.OpenFlag { - return h.flags -} - -// Read reads up to len(buf) bytes from the current position -func (h *MemoryFileHandle) Read(buf []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check read permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_RDONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for reading") - } - - h.mfs.mu.RLock() - defer h.mfs.mu.RUnlock() - - node, err := h.mfs.getNode(h.path) - if err != nil { - return 0, err - } - - if h.pos >= int64(len(node.Data)) { - return 0, io.EOF - } - - n := copy(buf, node.Data[h.pos:]) - h.pos += int64(n) - return n, nil -} - -// ReadAt reads len(buf) bytes from the specified offset (pread) -func (h *MemoryFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check read permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_RDONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for reading") - } - - h.mfs.mu.RLock() - defer h.mfs.mu.RUnlock() - - node, err := h.mfs.getNode(h.path) - if err != nil { - return 0, err - } - - if offset >= int64(len(node.Data)) { - return 0, io.EOF - } - - n := copy(buf, node.Data[offset:]) - return n, nil -} - -// Write writes data at the current position -func (h *MemoryFileHandle) Write(data []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check write permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_WRONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for writing") - } - - h.mfs.mu.Lock() - defer h.mfs.mu.Unlock() - - node, err := h.mfs.getNode(h.path) - if err != nil { - return 0, err - } - - // Handle append mode - writePos := h.pos - if h.flags&filesystem.O_APPEND != 0 { - writePos = int64(len(node.Data)) - } - - // Extend data if necessary - newSize := writePos + int64(len(data)) - if newSize > int64(len(node.Data)) { - newData := make([]byte, newSize) - copy(newData, node.Data) - node.Data = newData - } - - copy(node.Data[writePos:], data) - h.pos = writePos + int64(len(data)) - node.ModTime = time.Now() - - return len(data), nil -} - -// WriteAt writes data at the specified offset (pwrite) -func (h *MemoryFileHandle) WriteAt(data []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check write permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_WRONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for writing") - } - - h.mfs.mu.Lock() - defer h.mfs.mu.Unlock() - - node, err := h.mfs.getNode(h.path) - if err != nil { - return 0, err - } - - // Extend data if necessary - newSize := offset + int64(len(data)) - if newSize > int64(len(node.Data)) { - newData := make([]byte, newSize) - copy(newData, node.Data) - node.Data = newData - } - - copy(node.Data[offset:], data) - node.ModTime = time.Now() - - return len(data), nil -} - -// Seek moves the read/write position -func (h *MemoryFileHandle) Seek(offset int64, whence int) (int64, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - h.mfs.mu.RLock() - node, err := h.mfs.getNode(h.path) - h.mfs.mu.RUnlock() - if err != nil { - return 0, err - } - - var newPos int64 - switch whence { - case io.SeekStart: - newPos = offset - case io.SeekCurrent: - newPos = h.pos + offset - case io.SeekEnd: - newPos = int64(len(node.Data)) + offset - default: - return 0, fmt.Errorf("invalid whence: %d", whence) - } - - if newPos < 0 { - return 0, fmt.Errorf("negative position") - } - - h.pos = newPos - return h.pos, nil -} - -// Sync synchronizes the file data to storage (no-op for in-memory) -func (h *MemoryFileHandle) Sync() error { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return fmt.Errorf("handle closed") - } - // No-op for in-memory storage - return nil -} - -// Close closes the handle and releases resources -func (h *MemoryFileHandle) Close() error { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return nil - } - - h.closed = true - - // Remove from MemoryFS handles map - h.mfs.handlesMu.Lock() - delete(h.mfs.handles, h.id) - h.mfs.handlesMu.Unlock() - - return nil -} - -// Stat returns file information -func (h *MemoryFileHandle) Stat() (*filesystem.FileInfo, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return nil, fmt.Errorf("handle closed") - } - - return h.mfs.Stat(h.path) -} - -// OpenHandle opens a file and returns a handle for stateful operations -func (mfs *MemoryFS) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - mfs.mu.Lock() - defer mfs.mu.Unlock() - - path = filesystem.NormalizePath(path) - - // Check if file exists - node, err := mfs.getNode(path) - fileExists := err == nil && node != nil - - // Handle O_EXCL: fail if file exists - if flags&filesystem.O_EXCL != 0 && fileExists { - return nil, fmt.Errorf("file already exists: %s", path) - } - - // Handle O_CREATE: create file if it doesn't exist - if flags&filesystem.O_CREATE != 0 && !fileExists { - parent, name, err := mfs.getParentNode(path) - if err != nil { - return nil, fmt.Errorf("parent directory not found: %s", path) - } - node = &Node{ - Name: name, - IsDir: false, - Data: []byte{}, - Mode: mode, - ModTime: time.Now(), - Children: nil, - } - parent.Children[name] = node - } else if !fileExists { - return nil, fmt.Errorf("file not found: %s", path) - } - - if node.IsDir { - return nil, fmt.Errorf("is a directory: %s", path) - } - - // Handle O_TRUNC: truncate file - if flags&filesystem.O_TRUNC != 0 { - node.Data = []byte{} - node.ModTime = time.Now() - } - - // Create handle with auto-incremented ID - mfs.handlesMu.Lock() - handleID := mfs.nextHandleID - mfs.nextHandleID++ - handle := &MemoryFileHandle{ - id: handleID, - path: path, - flags: flags, - mfs: mfs, - pos: 0, - } - mfs.handles[handleID] = handle - mfs.handlesMu.Unlock() - - return handle, nil -} - -// GetHandle retrieves an existing handle by its ID -func (mfs *MemoryFS) GetHandle(id int64) (filesystem.FileHandle, error) { - mfs.handlesMu.RLock() - defer mfs.handlesMu.RUnlock() - - handle, exists := mfs.handles[id] - if !exists { - return nil, filesystem.ErrNotFound - } - - return handle, nil -} - -// CloseHandle closes a handle by its ID -func (mfs *MemoryFS) CloseHandle(id int64) error { - mfs.handlesMu.RLock() - handle, exists := mfs.handles[id] - mfs.handlesMu.RUnlock() - - if !exists { - return filesystem.ErrNotFound - } - - return handle.Close() -} - -// Ensure MemoryFS implements HandleFS interface -var _ filesystem.HandleFS = (*MemoryFS)(nil) - diff --git a/third_party/agfs/agfs-server/pkg/plugins/memfs/memoryfs_test.go b/third_party/agfs/agfs-server/pkg/plugins/memfs/memoryfs_test.go deleted file mode 100644 index 462cecc85..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/memfs/memoryfs_test.go +++ /dev/null @@ -1,839 +0,0 @@ -package memfs - -import ( - "bytes" - "io" - "testing" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -// readIgnoreEOF reads file content, ignoring io.EOF which is expected at end of file -func readIgnoreEOF(fs *MemoryFS, path string) ([]byte, error) { - content, err := fs.Read(path, 0, -1) - if err == io.EOF { - return content, nil - } - return content, err -} - -func TestMemoryFSCreate(t *testing.T) { - fs := NewMemoryFS() - - // Create a file - err := fs.Create("/test.txt") - if err != nil { - t.Fatalf("Create failed: %v", err) - } - - // Verify file exists - info, err := fs.Stat("/test.txt") - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - if info.IsDir { - t.Error("Expected file, got directory") - } - if info.Size != 0 { - t.Errorf("Expected size 0, got %d", info.Size) - } - - // Create duplicate should fail - err = fs.Create("/test.txt") - if err == nil { - t.Error("Expected error for duplicate file") - } -} - -func TestMemoryFSWriteBasic(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - // Write with create flag - data := []byte("Hello, World!") - n, err := fs.Write(path, data, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - if n != int64(len(data)) { - t.Errorf("Write returned %d, want %d", n, len(data)) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if !bytes.Equal(content, data) { - t.Errorf("Read content mismatch: got %q, want %q", content, data) - } -} - -func TestMemoryFSWriteWithOffset(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello, World!"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Write at offset (pwrite-style) - _, err = fs.Write(path, []byte("XXXXX"), 7, filesystem.WriteFlagNone) - if err != nil { - t.Fatalf("Write at offset failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - expected := "Hello, XXXXX!" - if string(content) != expected { - t.Errorf("Content mismatch: got %q, want %q", string(content), expected) - } -} - -func TestMemoryFSWriteExtend(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Write at offset beyond file size (should extend) - _, err = fs.Write(path, []byte("World"), 10, filesystem.WriteFlagNone) - if err != nil { - t.Fatalf("Write at extended offset failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if len(content) != 15 { - t.Errorf("Expected length 15, got %d", len(content)) - } - // Check beginning and end - if string(content[:5]) != "Hello" { - t.Errorf("Beginning mismatch: got %q", string(content[:5])) - } - if string(content[10:]) != "World" { - t.Errorf("End mismatch: got %q", string(content[10:])) - } -} - -func TestMemoryFSWriteAppend(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Append data - _, err = fs.Write(path, []byte(", World!"), 0, filesystem.WriteFlagAppend) - if err != nil { - t.Fatalf("Append failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - expected := "Hello, World!" - if string(content) != expected { - t.Errorf("Content mismatch: got %q, want %q", string(content), expected) - } -} - -func TestMemoryFSWriteTruncate(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - // Create file with initial content - _, err := fs.Write(path, []byte("Hello, World!"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Initial write failed: %v", err) - } - - // Write with truncate - _, err = fs.Write(path, []byte("Hi"), -1, filesystem.WriteFlagTruncate) - if err != nil { - t.Fatalf("Truncate write failed: %v", err) - } - - // Read back - content, err := readIgnoreEOF(fs, path) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if string(content) != "Hi" { - t.Errorf("Content mismatch: got %q, want %q", string(content), "Hi") - } -} - -func TestMemoryFSWriteCreateExclusive(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - // Create new file with exclusive flag - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate|filesystem.WriteFlagExclusive) - if err != nil { - t.Fatalf("Exclusive create failed: %v", err) - } - - // Second exclusive create should fail - _, err = fs.Write(path, []byte("World"), -1, filesystem.WriteFlagCreate|filesystem.WriteFlagExclusive) - if err == nil { - t.Error("Expected error for exclusive create on existing file") - } -} - -func TestMemoryFSWriteNonExistent(t *testing.T) { - fs := NewMemoryFS() - path := "/nonexistent.txt" - - // Write to non-existent file without create flag should fail - _, err := fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagNone) - if err == nil { - t.Error("Expected error for writing to non-existent file without create flag") - } - - // Write with create flag should succeed - _, err = fs.Write(path, []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write with create flag failed: %v", err) - } -} - -func TestMemoryFSReadWithOffset(t *testing.T) { - fs := NewMemoryFS() - path := "/test.txt" - - data := []byte("Hello, World!") - _, err := fs.Write(path, data, -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Read from offset - content, err := fs.Read(path, 7, 5) - if err != nil && err != io.EOF { - t.Fatalf("Read with offset failed: %v", err) - } - if string(content) != "World" { - t.Errorf("Read content mismatch: got %q, want %q", string(content), "World") - } - - // Read all from offset - content, err = fs.Read(path, 7, -1) - if err != nil && err != io.EOF { - t.Fatalf("Read all from offset failed: %v", err) - } - if string(content) != "World!" { - t.Errorf("Read content mismatch: got %q, want %q", string(content), "World!") - } -} - -func TestMemoryFSMkdir(t *testing.T) { - fs := NewMemoryFS() - - // Create directory - err := fs.Mkdir("/testdir", 0755) - if err != nil { - t.Fatalf("Mkdir failed: %v", err) - } - - // Verify directory exists - info, err := fs.Stat("/testdir") - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - if !info.IsDir { - t.Error("Expected directory, got file") - } - if info.Mode != 0755 { - t.Errorf("Mode mismatch: got %o, want 755", info.Mode) - } -} - -func TestMemoryFSRemove(t *testing.T) { - fs := NewMemoryFS() - - // Create and remove file - err := fs.Create("/test.txt") - if err != nil { - t.Fatalf("Create failed: %v", err) - } - - err = fs.Remove("/test.txt") - if err != nil { - t.Fatalf("Remove failed: %v", err) - } - - // Verify file is removed - _, err = fs.Stat("/test.txt") - if err == nil { - t.Error("Expected error for removed file") - } -} - -func TestMemoryFSRename(t *testing.T) { - fs := NewMemoryFS() - - // Create file - data := []byte("Hello, World!") - _, err := fs.Write("/old.txt", data, -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Rename - err = fs.Rename("/old.txt", "/new.txt") - if err != nil { - t.Fatalf("Rename failed: %v", err) - } - - // Verify old path doesn't exist - _, err = fs.Stat("/old.txt") - if err == nil { - t.Error("Old path should not exist") - } - - // Verify new path exists with same content - content, err := fs.Read("/new.txt", 0, -1) - if err != nil && err != io.EOF { - t.Fatalf("Read new path failed: %v", err) - } - if !bytes.Equal(content, data) { - t.Errorf("Content mismatch after rename") - } -} - -func TestMemoryFSReadDir(t *testing.T) { - fs := NewMemoryFS() - - // Create some files and directories - fs.Mkdir("/dir1", 0755) - fs.Create("/file1.txt") - fs.Create("/file2.txt") - - // Read root directory - infos, err := fs.ReadDir("/") - if err != nil { - t.Fatalf("ReadDir failed: %v", err) - } - - if len(infos) != 3 { - t.Errorf("Expected 3 entries, got %d", len(infos)) - } - - // Verify entries - names := make(map[string]bool) - for _, info := range infos { - names[info.Name] = true - } - - if !names["dir1"] || !names["file1.txt"] || !names["file2.txt"] { - t.Errorf("Missing expected entries: %v", names) - } -} - -func TestMemoryFSChmod(t *testing.T) { - fs := NewMemoryFS() - - // Create file - fs.Create("/test.txt") - - // Change mode - err := fs.Chmod("/test.txt", 0600) - if err != nil { - t.Fatalf("Chmod failed: %v", err) - } - - // Verify mode - info, err := fs.Stat("/test.txt") - if err != nil { - t.Fatalf("Stat failed: %v", err) - } - if info.Mode != 0600 { - t.Errorf("Mode mismatch: got %o, want 600", info.Mode) - } -} - -// Note: Touch, Truncate, WriteAt, and GetCapabilities are optional extension interfaces -// MemFS may or may not implement them. These tests are skipped if not implemented. - -// ============================================================================ -// HandleFS Tests -// ============================================================================ - -func TestMemoryFSOpenHandle(t *testing.T) { - fs := NewMemoryFS() - - // Create a file first - _, err := fs.Write("/test.txt", []byte("Hello"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Open handle for reading - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - if handle.ID() == 0 { - t.Error("Handle ID should not be zero") - } - if handle.Path() != "/test.txt" { - t.Errorf("Path mismatch: got %s, want /test.txt", handle.Path()) - } - if handle.Flags() != filesystem.O_RDONLY { - t.Errorf("Flags mismatch: got %d, want %d", handle.Flags(), filesystem.O_RDONLY) - } -} - -func TestMemoryFSOpenHandleCreate(t *testing.T) { - fs := NewMemoryFS() - - // Open with O_CREATE should create file - handle, err := fs.OpenHandle("/newfile.txt", filesystem.O_RDWR|filesystem.O_CREATE, 0644) - if err != nil { - t.Fatalf("OpenHandle with O_CREATE failed: %v", err) - } - defer handle.Close() - - // Verify file was created - _, err = fs.Stat("/newfile.txt") - if err != nil { - t.Error("File should exist after O_CREATE") - } -} - -func TestMemoryFSOpenHandleExclusive(t *testing.T) { - fs := NewMemoryFS() - - // Create a file - _, err := fs.Write("/existing.txt", []byte("data"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Open with O_EXCL should fail for existing file - _, err = fs.OpenHandle("/existing.txt", filesystem.O_RDWR|filesystem.O_CREATE|filesystem.O_EXCL, 0644) - if err == nil { - t.Error("O_EXCL should fail for existing file") - } - - // O_CREATE|O_EXCL should work for new file - handle, err := fs.OpenHandle("/exclusive.txt", filesystem.O_RDWR|filesystem.O_CREATE|filesystem.O_EXCL, 0644) - if err != nil { - t.Fatalf("O_EXCL failed for new file: %v", err) - } - handle.Close() -} - -func TestMemoryFSOpenHandleTruncate(t *testing.T) { - fs := NewMemoryFS() - - // Create a file with content - _, err := fs.Write("/truncate.txt", []byte("Hello, World!"), -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Open with O_TRUNC should truncate - handle, err := fs.OpenHandle("/truncate.txt", filesystem.O_RDWR|filesystem.O_TRUNC, 0644) - if err != nil { - t.Fatalf("OpenHandle with O_TRUNC failed: %v", err) - } - handle.Close() - - // Verify file is empty - content, _ := readIgnoreEOF(fs, "/truncate.txt") - if len(content) != 0 { - t.Errorf("File should be empty after O_TRUNC, got %d bytes", len(content)) - } -} - -func TestMemoryFileHandleRead(t *testing.T) { - fs := NewMemoryFS() - data := []byte("Hello, World!") - _, _ = fs.Write("/test.txt", data, -1, filesystem.WriteFlagCreate) - - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // Read first 5 bytes - buf := make([]byte, 5) - n, err := handle.Read(buf) - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if n != 5 || string(buf) != "Hello" { - t.Errorf("Read mismatch: got %q, want 'Hello'", string(buf[:n])) - } - - // Read next 8 bytes - buf = make([]byte, 8) - n, err = handle.Read(buf) - if err != nil { - t.Fatalf("Second read failed: %v", err) - } - if string(buf[:n]) != ", World!" { - t.Errorf("Second read mismatch: got %q", string(buf[:n])) - } - - // Read at EOF - buf = make([]byte, 10) - _, err = handle.Read(buf) - if err != io.EOF { - t.Errorf("Expected EOF, got %v", err) - } -} - -func TestMemoryFileHandleReadAt(t *testing.T) { - fs := NewMemoryFS() - data := []byte("Hello, World!") - _, _ = fs.Write("/test.txt", data, -1, filesystem.WriteFlagCreate) - - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // ReadAt offset 7 - buf := make([]byte, 5) - n, err := handle.ReadAt(buf, 7) - if err != nil { - t.Fatalf("ReadAt failed: %v", err) - } - if string(buf[:n]) != "World" { - t.Errorf("ReadAt mismatch: got %q, want 'World'", string(buf[:n])) - } - - // ReadAt should not affect position - Read should still start from 0 - buf = make([]byte, 5) - n, err = handle.Read(buf) - if err != nil { - t.Fatalf("Read after ReadAt failed: %v", err) - } - if string(buf[:n]) != "Hello" { - t.Errorf("Read position affected by ReadAt: got %q", string(buf[:n])) - } -} - -func TestMemoryFileHandleWrite(t *testing.T) { - fs := NewMemoryFS() - - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDWR|filesystem.O_CREATE, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // Write data - n, err := handle.Write([]byte("Hello")) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - if n != 5 { - t.Errorf("Write returned %d, want 5", n) - } - - // Write more - n, err = handle.Write([]byte(", World!")) - if err != nil { - t.Fatalf("Second write failed: %v", err) - } - - // Verify content - content, _ := readIgnoreEOF(fs, "/test.txt") - if string(content) != "Hello, World!" { - t.Errorf("Content mismatch: got %q", string(content)) - } -} - -func TestMemoryFileHandleWriteAt(t *testing.T) { - fs := NewMemoryFS() - _, _ = fs.Write("/test.txt", []byte("Hello, World!"), -1, filesystem.WriteFlagCreate) - - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDWR, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // WriteAt offset 7 - n, err := handle.WriteAt([]byte("XXXXX"), 7) - if err != nil { - t.Fatalf("WriteAt failed: %v", err) - } - if n != 5 { - t.Errorf("WriteAt returned %d, want 5", n) - } - - // Verify - content, _ := readIgnoreEOF(fs, "/test.txt") - if string(content) != "Hello, XXXXX!" { - t.Errorf("Content mismatch: got %q", string(content)) - } -} - -func TestMemoryFileHandleSeek(t *testing.T) { - fs := NewMemoryFS() - data := []byte("Hello, World!") - _, _ = fs.Write("/test.txt", data, -1, filesystem.WriteFlagCreate) - - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // Seek to offset 7 from start - pos, err := handle.Seek(7, io.SeekStart) - if err != nil { - t.Fatalf("Seek failed: %v", err) - } - if pos != 7 { - t.Errorf("Seek position: got %d, want 7", pos) - } - - // Read after seek - buf := make([]byte, 5) - n, err := handle.Read(buf) - if err != nil { - t.Fatalf("Read after seek failed: %v", err) - } - if string(buf[:n]) != "World" { - t.Errorf("Read mismatch: got %q", string(buf[:n])) - } - - // Seek from end - pos, err = handle.Seek(-6, io.SeekEnd) - if err != nil { - t.Fatalf("Seek from end failed: %v", err) - } - if pos != 7 { - t.Errorf("Seek from end position: got %d, want 7", pos) - } - - // Seek from current - pos, err = handle.Seek(-2, io.SeekCurrent) - if err != nil { - t.Fatalf("Seek from current failed: %v", err) - } - if pos != 5 { - t.Errorf("Seek from current position: got %d, want 5", pos) - } -} - -func TestMemoryFileHandleAppend(t *testing.T) { - fs := NewMemoryFS() - _, _ = fs.Write("/test.txt", []byte("Hello"), -1, filesystem.WriteFlagCreate) - - handle, err := fs.OpenHandle("/test.txt", filesystem.O_WRONLY|filesystem.O_APPEND, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // Write in append mode - _, err = handle.Write([]byte(", World!")) - if err != nil { - t.Fatalf("Write in append mode failed: %v", err) - } - - // Verify content - content, _ := readIgnoreEOF(fs, "/test.txt") - if string(content) != "Hello, World!" { - t.Errorf("Content mismatch: got %q", string(content)) - } -} - -func TestMemoryFSGetHandle(t *testing.T) { - fs := NewMemoryFS() - _, _ = fs.Write("/test.txt", []byte("data"), -1, filesystem.WriteFlagCreate) - - // Open handle - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - id := handle.ID() - - // Get handle by ID - retrieved, err := fs.GetHandle(id) - if err != nil { - t.Fatalf("GetHandle failed: %v", err) - } - if retrieved.ID() != id { - t.Error("Retrieved handle has different ID") - } - - // Close handle - handle.Close() - - // GetHandle should fail after close - _, err = fs.GetHandle(id) - if err != filesystem.ErrNotFound { - t.Errorf("Expected ErrNotFound after close, got %v", err) - } -} - -func TestMemoryFSCloseHandle(t *testing.T) { - fs := NewMemoryFS() - _, _ = fs.Write("/test.txt", []byte("data"), -1, filesystem.WriteFlagCreate) - - // Open handle - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - id := handle.ID() - - // Close by ID - err = fs.CloseHandle(id) - if err != nil { - t.Fatalf("CloseHandle failed: %v", err) - } - - // Second close should fail - err = fs.CloseHandle(id) - if err != filesystem.ErrNotFound { - t.Errorf("Expected ErrNotFound for second close, got %v", err) - } -} - -func TestMemoryFileHandleReadPermission(t *testing.T) { - fs := NewMemoryFS() - _, _ = fs.Write("/test.txt", []byte("data"), -1, filesystem.WriteFlagCreate) - - // Open write-only - handle, err := fs.OpenHandle("/test.txt", filesystem.O_WRONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // Read should fail - buf := make([]byte, 10) - _, err = handle.Read(buf) - if err == nil { - t.Error("Read should fail on write-only handle") - } -} - -func TestMemoryFileHandleWritePermission(t *testing.T) { - fs := NewMemoryFS() - _, _ = fs.Write("/test.txt", []byte("data"), -1, filesystem.WriteFlagCreate) - - // Open read-only - handle, err := fs.OpenHandle("/test.txt", filesystem.O_RDONLY, 0644) - if err != nil { - t.Fatalf("OpenHandle failed: %v", err) - } - defer handle.Close() - - // Write should fail - _, err = handle.Write([]byte("new data")) - if err == nil { - t.Error("Write should fail on read-only handle") - } -} - -func TestMemoryFSOpenWrite(t *testing.T) { - fs := NewMemoryFS() - - // Create file - fs.Create("/test.txt") - - // Open for writing - w, err := fs.OpenWrite("/test.txt") - if err != nil { - t.Fatalf("OpenWrite failed: %v", err) - } - - // Write through the writer - data := []byte("Hello, World!") - n, err := w.Write(data) - if err != nil { - t.Fatalf("Writer.Write failed: %v", err) - } - if n != len(data) { - t.Errorf("Write returned %d, want %d", n, len(data)) - } - - // Close the writer (should flush) - err = w.Close() - if err != nil { - t.Fatalf("Writer.Close failed: %v", err) - } - - // Verify content - content, err := readIgnoreEOF(fs, "/test.txt") - if err != nil { - t.Fatalf("Read failed: %v", err) - } - if !bytes.Equal(content, data) { - t.Errorf("Content mismatch: got %q, want %q", content, data) - } -} - -func TestMemoryFSOpen(t *testing.T) { - fs := NewMemoryFS() - - // Create file with content - data := []byte("Hello, World!") - _, err := fs.Write("/test.txt", data, -1, filesystem.WriteFlagCreate) - if err != nil { - t.Fatalf("Write failed: %v", err) - } - - // Open for reading - note: Open uses Read internally which returns EOF on success - // So we need to check that r is not nil before using it - r, err := fs.Open("/test.txt") - if r == nil { - // This can happen if Open's internal Read returned only EOF with data - t.Skip("Open returned nil reader (internal Read behavior)") - } - if err != nil && err != io.EOF { - t.Fatalf("Open failed: %v", err) - } - - // Read through the reader - buf := make([]byte, 100) - n, err := r.Read(buf) - if err != nil && err != io.EOF { - t.Fatalf("Reader.Read failed: %v", err) - } - if n != len(data) { - t.Errorf("Read returned %d, want %d", n, len(data)) - } - if !bytes.Equal(buf[:n], data) { - t.Errorf("Content mismatch: got %q, want %q", buf[:n], data) - } - - // Close - err = r.Close() - if err != nil { - t.Fatalf("Reader.Close failed: %v", err) - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/proxyfs/README.md b/third_party/agfs/agfs-server/pkg/plugins/proxyfs/README.md deleted file mode 100644 index 61ac44e76..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/proxyfs/README.md +++ /dev/null @@ -1,487 +0,0 @@ -# ProxyFS Plugin - -An AGFS plugin that transparently proxies all file system operations to a remote AGFS HTTP API server. - -## Overview - -ProxyFS enables AGFS federation by allowing one AGFS instance to mount and access file systems from another remote AGFS server. All file operations are forwarded over HTTP to the remote server, making it possible to build distributed file system architectures. - -## Dynamic Mounting with AGFS Shell - -### Interactive Shell - -```bash -# Mount a single remote AGFS server -agfs:/> mount proxyfs /remote base_url=http://remote-server:8080/api/v1 - -# Mount multiple remote servers -agfs:/> mount proxyfs /dc1 base_url=http://dc1.example.com:8080/api/v1 -agfs:/> mount proxyfs /dc2 base_url=http://dc2.example.com:8080/api/v1 -agfs:/> mount proxyfs /backup base_url=https://backup.example.com:8443/api/v1 - -# Mount with HTTPS -agfs:/> mount proxyfs /secure base_url=https://secure-server.com:8443/api/v1 -``` - -### Direct Command - -```bash -# Mount remote server -uv run agfs mount proxyfs /remote base_url=http://remote:8080/api/v1 - -# Mount production server -uv run agfs mount proxyfs /prod base_url=https://prod.example.com/api/v1 -``` - -### Configuration Parameters - -| Parameter | Type | Required | Description | Example | -|-----------|--------|----------|------------------------------------------------|------------------------------------| -| base_url | string | Yes | Full URL to remote AGFS API including version | `http://remote:8080/api/v1` | - -**Important**: The `base_url` must include the API version path (e.g., `/api/v1`). - -### Usage After Mounting - -Once mounted, all operations under the mount point are forwarded to the remote server: - -```bash -# All these operations happen on the remote server -agfs:/> mkdir /remote/data -agfs:/> echo "hello" > /remote/data/file.txt -agfs:/> cat /remote/data/file.txt -hello -agfs:/> ls /remote/data -file.txt - -# Hot reload the proxy connection if needed -agfs:/> echo '' > /remote/reload -ProxyFS reloaded successfully -``` - -## Features - -- **Transparent Proxying**: All file system operations forwarded to remote AGFS server -- **Full API Compatibility**: Supports all AGFS file system operations -- **Health Checking**: Automatic connection validation on initialization -- **Hot Reload**: Reload proxy connection without restarting server -- **Configurable**: Remote server URL configurable via plugin config -- **Federation**: Build distributed AGFS architectures - -## Installation - -The ProxyFS plugin is built into the AGFS server. Simply import and mount it: - -```go -import "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/proxyfs" -``` - -## Quick Start - -### Basic Usage - -```go -package main - -import ( - "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/proxyfs" -) - -func main() { - // Create a mountable file system - mfs := mountablefs.NewMountableFS() - - // Create and mount ProxyFS plugin - plugin := proxyfs.NewProxyFSPlugin("http://remote-server:8080/api/v1") - err := plugin.Initialize(nil) - if err != nil { - panic(err) - } - - // Mount at /remote - mfs.Mount("/remote", plugin) - - // Now all operations under /remote are forwarded to remote server -} -``` - -### With Configuration - -```go -plugin := proxyfs.NewProxyFSPlugin("") - -config := map[string]interface{}{ - "base_url": "http://remote-server:8080/api/v1", -} - -err := plugin.Initialize(config) -if err != nil { - panic(err) -} - -mfs.Mount("/remote", plugin) -``` - -## Configuration - -The plugin accepts the following configuration parameters: - -| Parameter | Type | Description | Example | -|-----------|--------|------------------------------------------------|------------------------------------| -| base_url | string | Full URL to remote AGFS API including version | `http://remote:8080/api/v1` | - -**Important**: The `base_url` must include the API version path (e.g., `/api/v1`). - -## Usage Examples - -Once mounted, the ProxyFS behaves like any other AGFS plugin: - -### Via agfs shell - -```bash -# All operations are executed on the remote server -agfs:/> mkdir /remote/memfs -agfs:/> echo "hello" > /remote/memfs/file.txt -agfs:/> cat /remote/memfs/file.txt -hello -agfs:/> ls /remote/memfs -file.txt - -# Hot reload the proxy connection -agfs:/> echo '' > /remote/reload -ProxyFS reloaded successfully -``` - -### Via API - -```bash -# Create directory on remote server -curl -X POST "http://localhost:8080/api/v1/directories?path=/remote/memfs" - -# Write file on remote server -curl -X PUT "http://localhost:8080/api/v1/files?path=/remote/memfs/file.txt" \ - -d "hello" - -# Read file from remote server -curl "http://localhost:8080/api/v1/files?path=/remote/memfs/file.txt" -``` - -### Programmatic Access - -```go -// Get the file system from plugin -fs := plugin.GetFileSystem() - -// All operations are proxied to remote server -err := fs.Mkdir("/memfs", 0755) -_, err = fs.Write("/memfs/file.txt", []byte("content")) -data, err := fs.Read("/memfs/file.txt") -files, err := fs.ReadDir("/memfs") -``` - -## Architecture - -``` -┌─────────────────────────────────────┐ -│ Local AGFS Server (Port 8080) │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ MountableFS │ │ -│ │ │ │ -│ │ /remote → ProxyFS │ │ -│ │ ↓ │ │ -│ │ HTTP Client │ │ -│ └──────────────────────────────┘ │ -└─────────────────────────────────────┘ - ↓ HTTP -┌─────────────────────────────────────┐ -│ Remote AGFS Server (Port 9090) │ -│ │ -│ ┌──────────────────────────────┐ │ -│ │ HTTP API Handler │ │ -│ │ ↓ │ │ -│ │ Actual File System │ │ -│ │ (MemFS, QueueFS, etc.) │ │ -│ └──────────────────────────────┘ │ -└─────────────────────────────────────┘ -``` - -## Hot Reload Feature - -ProxyFS includes a special `/reload` virtual file that allows you to reload the connection to the remote server without restarting. - -### When to Use Hot Reload - -- Remote server was restarted -- Network connection was interrupted -- Connection pool needs refreshing -- Switching between backend servers - -### Usage - -```bash -# Via CLI -agfs:/> echo '' > /proxyfs/reload -ProxyFS reloaded successfully - -# Via API -curl -X PUT "http://localhost:8080/api/v1/files?path=/proxyfs/reload" -d "" - -# Check reload file info -agfs:/> stat /proxyfs/reload -File: reload -Type: File -Mode: 200 (write-only) -Meta.type: control -Meta.description: Write to this file to reload proxy connection -``` - -### How It Works - -1. Writing to `/reload` triggers the reload mechanism -2. A new HTTP client is created with the same base URL -3. Health check is performed to verify the new connection -4. If successful, the old client is replaced -5. All subsequent requests use the new connection - -### Reload Process - -```go -// Internal reload process -func (p *ProxyFS) Reload() error { - // Create new client - p.client = client.NewClient(p.baseURL) - - // Test connection - if err := p.client.Health(); err != nil { - return fmt.Errorf("failed to connect after reload: %w", err) - } - - return nil -} -``` - -## Use Cases - -### 1. Remote File System Access - -Access files on a remote AGFS server as if they were local: - -```go -// Mount remote server's file system -plugin := proxyfs.NewProxyFSPlugin("http://remote:8080/api/v1") -mfs.Mount("/remote", plugin) - -// Access remote files locally -data, _ := mfs.Read("/remote/memfs/config.json") -``` - -### 2. AGFS Federation - -Build a federated AGFS architecture with multiple remote servers: - -```go -// Mount multiple remote servers -proxy1 := proxyfs.NewProxyFSPlugin("http://server1:8080/api/v1") -proxy2 := proxyfs.NewProxyFSPlugin("http://server2:8080/api/v1") -proxy3 := proxyfs.NewProxyFSPlugin("http://server3:8080/api/v1") - -mfs.Mount("/region-us", proxy1) -mfs.Mount("/region-eu", proxy2) -mfs.Mount("/region-asia", proxy3) -``` - -### 3. Service Discovery - -Access services from remote AGFS instances: - -```go -// Mount remote queue service -plugin := proxyfs.NewProxyFSPlugin("http://queue-server:8080/api/v1") -mfs.Mount("/remote-queue", plugin) - -// Use remote queue -mfs.Write("/remote-queue/queue/enqueue", []byte("task-123")) -``` - -### 4. Cross-Data Center Access - -Access file systems across different data centers: - -```go -// DC1 -dc1 := proxyfs.NewProxyFSPlugin("http://dc1.example.com/api/v1") -mfs.Mount("/dc1", dc1) - -// DC2 -dc2 := proxyfs.NewProxyFSPlugin("http://dc2.example.com/api/v1") -mfs.Mount("/dc2", dc2) -``` - -## Implementation Details - -### HTTP Client - -ProxyFS uses the AGFS Go client library (`pkg/client`) internally, which provides: -- 30-second default timeout -- Automatic error handling -- Type-safe API calls -- Connection pooling - -### Error Handling - -Errors from the remote server are propagated to the caller with full context: - -```go -data, err := fs.Read("/nonexistent") -// Error: HTTP 404: file not found -``` - -### Health Checking - -On initialization, ProxyFS performs a health check to verify connectivity: - -```go -err := plugin.Initialize(nil) -// Returns error if remote server is unreachable -``` - -## Supported Operations - -ProxyFS supports all file system operations: - -- ✅ Create -- ✅ Read -- ✅ Write -- ✅ Remove / RemoveAll -- ✅ Mkdir -- ✅ ReadDir -- ✅ Stat -- ✅ Rename -- ✅ Chmod -- ✅ Open (ReadCloser) -- ✅ OpenWrite (WriteCloser) - -## Performance Considerations - -### Network Latency -All operations incur network latency. For latency-sensitive applications, consider: -- Using local caching -- Batching operations -- Deploying ProxyFS servers closer to clients - -### Connection Management -The underlying HTTP client uses connection pooling. For high-throughput scenarios: -- Adjust HTTP client transport settings -- Increase MaxIdleConns and MaxIdleConnsPerHost -- Configure appropriate timeouts - -### Error Recovery -Network failures are surfaced as errors. Implement retry logic for critical operations: - -```go -func readWithRetry(fs filesystem.FileSystem, path string, retries int) ([]byte, error) { - var err error - var data []byte - for i := 0; i < retries; i++ { - data, err = fs.Read(path) - if err == nil { - return data, nil - } - time.Sleep(time.Second * time.Duration(i+1)) - } - return nil, err -} -``` - -## Testing - -Run the test suite: - -```bash -go test ./pkg/plugins/proxyfs -v -``` - -The tests use `httptest` to create mock AGFS servers, ensuring reliable testing without external dependencies. - -## Security Considerations - -### Authentication -ProxyFS currently does not implement authentication. For production use: -- Use TLS/HTTPS for encrypted communication -- Implement authentication at the HTTP client level -- Use network-level security (VPN, private networks) - -### Authorization -Authorization is handled by the remote AGFS server. Ensure proper access controls are configured on the remote server. - -### Network Security -- Use HTTPS in production: `https://remote-server:8443/api/v1` -- Implement mutual TLS for server authentication -- Use firewall rules to restrict access - -## Limitations - -1. **Synchronous Operations**: All operations are synchronous HTTP calls -2. **No Caching**: No local caching of remote data -3. **Network Dependent**: Requires stable network connectivity -4. **No Streaming**: Large files are loaded entirely into memory - -## Future Enhancements - -Potential improvements: - -- [ ] Local caching for frequently accessed files -- [ ] Streaming support for large files -- [ ] Authentication/authorization support -- [ ] Connection pooling configuration -- [ ] Retry logic with exponential backoff -- [ ] Compression for network transfer -- [ ] Batch operations support - -## Example: Complete Setup - -```go -package main - -import ( - "log" - "net/http" - - "github.com/c4pt0r/agfs/agfs-server/pkg/handlers" - "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugins/proxyfs" -) - -func main() { - // Create local AGFS - mfs := mountablefs.NewMountableFS() - - // Mount remote AGFS servers - remote1 := proxyfs.NewProxyFSPlugin("http://remote1:8080/api/v1") - if err := remote1.Initialize(nil); err != nil { - log.Fatalf("Failed to initialize remote1: %v", err) - } - mfs.Mount("/remote1", remote1) - - remote2 := proxyfs.NewProxyFSPlugin("http://remote2:8080/api/v1") - if err := remote2.Initialize(nil); err != nil { - log.Fatalf("Failed to initialize remote2: %v", err) - } - mfs.Mount("/remote2", remote2) - - // Setup HTTP handlers - handler := handlers.NewHandler(mfs) - mux := http.NewServeMux() - handler.SetupRoutes(mux) - - // Start server - log.Println("Starting federated AGFS server on :8080") - log.Fatal(http.ListenAndServe(":8080", mux)) -} -``` - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/proxyfs/examples/helloworld_agfs_server.py b/third_party/agfs/agfs-server/pkg/plugins/proxyfs/examples/helloworld_agfs_server.py deleted file mode 100644 index 8dc5f7660..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/proxyfs/examples/helloworld_agfs_server.py +++ /dev/null @@ -1,331 +0,0 @@ -#!/usr/bin/env python3 -""" -HelloWorld AGFS Server - A simple Python implementation of AGFS HTTP API - -This server implements a minimal read-only file system with a single file: - /hello.txt -> "Hello, World!" - -It can be used with ProxyFS to demonstrate remote file system access. - -Usage: - python3 helloworld_agfs_server.py [--port PORT] - -Example: - # Start the server - python3 helloworld_agfs_server.py --port 9090 -""" - -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import urlparse, parse_qs -import json -import argparse -from datetime import datetime, timezone - - -class HelloWorldFileSystem: - """A simple in-memory read-only file system""" - - def __init__(self): - # Define our simple file system structure - now = datetime.now(timezone.utc).isoformat() - self.files = { - "/": { - "name": "/", - "isDir": True, - "size": 0, - "mode": 0o755, - "modTime": now, - "meta": {"type": "directory"} - }, - "/hello.txt": { - "name": "hello.txt", - "isDir": False, - "size": 14, - "mode": 0o644, - "modTime": now, - "content": b"Hello, World!\n", - "meta": {"type": "file", "description": "A friendly greeting"} - }, - "/README.md": { - "name": "README.md", - "isDir": False, - "size": 0, - "mode": 0o444, - "modTime": now, - "content": b"""# HelloWorld FileSystem - -This is a simple read-only file system implemented in Python. - -## Files - -- `/hello.txt` - A simple greeting message -- `/README.md` - This file - -## Features - -- Read-only access -- Compatible with AGFS HTTP API -- Can be mounted via ProxyFS - -## Try it! - -```bash -cat /hello.txt -``` -""", - "meta": {"type": "markdown"} - } - } - - def list_directory(self, path): - """List directory contents""" - if path not in self.files: - raise FileNotFoundError(f"No such directory: {path}") - - if not self.files[path]["isDir"]: - raise NotADirectoryError(f"Not a directory: {path}") - - # Return all files in root directory - if path == "/": - return [ - { - "name": info["name"], - "size": info["size"], - "mode": info["mode"], - "modTime": info["modTime"], - "isDir": info["isDir"], - "meta": info.get("meta", {}) - } - for p, info in self.files.items() - if p != "/" and not info["isDir"] - ] - return [] - - def read_file(self, path, offset=0, size=-1): - """Read file content with optional offset and size - - Args: - path: File path - offset: Starting position (default: 0) - size: Number of bytes to read (-1 means read all) - - Returns: - tuple: (data, is_eof) where is_eof indicates if we reached end of file - """ - if path not in self.files: - raise FileNotFoundError(f"No such file: {path}") - - if self.files[path]["isDir"]: - raise IsADirectoryError(f"Is a directory: {path}") - - content = self.files[path]["content"] - content_len = len(content) - - # Validate offset - if offset < 0: - offset = 0 - if offset >= content_len: - return b"", True # EOF - - # Calculate end position - if size < 0: - # Read all remaining data - end = content_len - else: - end = offset + size - if end > content_len: - end = content_len - - # Extract the range - result = content[offset:end] - - # Check if we reached EOF - is_eof = (end >= content_len) - - return result, is_eof - - def stat(self, path): - """Get file/directory information""" - if path not in self.files: - raise FileNotFoundError(f"No such file or directory: {path}") - - info = self.files[path] - return { - "name": info["name"], - "size": info["size"], - "mode": info["mode"], - "modTime": info["modTime"], - "isDir": info["isDir"], - "meta": info.get("meta", {}) - } - - -class PFSRequestHandler(BaseHTTPRequestHandler): - """HTTP request handler implementing AGFS API""" - - # Class-level file system instance - fs = HelloWorldFileSystem() - - def _send_json_response(self, status_code, data): - """Send JSON response""" - self.send_response(status_code) - self.send_header('Content-Type', 'application/json') - self.end_headers() - self.wfile.write(json.dumps(data).encode('utf-8')) - - def _send_binary_response(self, status_code, data): - """Send binary response""" - self.send_response(status_code) - self.send_header('Content-Type', 'application/octet-stream') - self.end_headers() - self.wfile.write(data) - - def _send_error_response(self, status_code, error_message): - """Send error response""" - self._send_json_response(status_code, {"error": error_message}) - - def _get_path_param(self): - """Extract path parameter from query string""" - parsed_url = urlparse(self.path) - query_params = parse_qs(parsed_url.query) - path = query_params.get('path', ['/'])[0] - return path - - def _get_offset_size_params(self): - """Extract offset and size parameters from query string""" - parsed_url = urlparse(self.path) - query_params = parse_qs(parsed_url.query) - - offset = 0 - size = -1 - - if 'offset' in query_params: - try: - offset = int(query_params['offset'][0]) - except (ValueError, IndexError): - pass - - if 'size' in query_params: - try: - size = int(query_params['size'][0]) - except (ValueError, IndexError): - pass - - return offset, size - - def do_GET(self): - """Handle GET requests""" - parsed_url = urlparse(self.path) - endpoint = parsed_url.path - - try: - # Health check - if endpoint == '/api/v1/health': - self._send_json_response(200, {"status": "healthy"}) - return - - # Read file - elif endpoint == '/api/v1/files': - path = self._get_path_param() - offset, size = self._get_offset_size_params() - try: - content, is_eof = self.fs.read_file(path, offset, size) - self._send_binary_response(200, content) - except FileNotFoundError as e: - self._send_error_response(404, str(e)) - except IsADirectoryError as e: - self._send_error_response(400, str(e)) - return - - # List directory - elif endpoint == '/api/v1/directories': - path = self._get_path_param() - try: - files = self.fs.list_directory(path) - self._send_json_response(200, {"files": files}) - except FileNotFoundError as e: - self._send_error_response(404, str(e)) - except NotADirectoryError as e: - self._send_error_response(400, str(e)) - return - - # Stat - elif endpoint == '/api/v1/stat': - path = self._get_path_param() - try: - info = self.fs.stat(path) - self._send_json_response(200, info) - except FileNotFoundError as e: - self._send_error_response(404, str(e)) - return - - else: - self._send_error_response(404, f"Endpoint not found: {endpoint}") - - except Exception as e: - self._send_error_response(500, f"Internal server error: {str(e)}") - - def do_POST(self): - """Handle POST requests - not supported in read-only FS""" - self._send_error_response(403, "This is a read-only file system") - - def do_PUT(self): - """Handle PUT requests - not supported in read-only FS""" - self._send_error_response(403, "This is a read-only file system") - - def do_DELETE(self): - """Handle DELETE requests - not supported in read-only FS""" - self._send_error_response(403, "This is a read-only file system") - - def log_message(self, format, *args): - """Override to customize logging""" - print(f"[{self.log_date_time_string()}] {format % args}") - - -def main(): - parser = argparse.ArgumentParser( - description='HelloWorld AGFS Server - A simple read-only file system' - ) - parser.add_argument( - '--port', - type=int, - default=9091, - help='Port to listen on (default: 9090)' - ) - parser.add_argument( - '--host', - type=str, - default='0.0.0.0', - help='Host to bind to (default: 0.0.0.0)' - ) - args = parser.parse_args() - - server_address = (args.host, args.port) - httpd = HTTPServer(server_address, PFSRequestHandler) - - print(f""" -╔═══════════════════════════════════════════════════════════════╗ -║ HelloWorld FS Server ║ -║ A simple read-only mock agfs http service ║ -╚═══════════════════════════════════════════════════════════════╝ - -Server running at: http://{args.host}:{args.port} -API base URL: http://localhost:{args.port}/api/v1 - -Files available: - /hello.txt - A friendly greeting - /README.md - Documentation - -Press Ctrl+C to stop the server. -""") - - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\n\nShutting down server...") - httpd.shutdown() - print("Server stopped.") - - -if __name__ == '__main__': - main() diff --git a/third_party/agfs/agfs-server/pkg/plugins/proxyfs/proxyfs.go b/third_party/agfs/agfs-server/pkg/plugins/proxyfs/proxyfs.go deleted file mode 100644 index c7a591c48..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/proxyfs/proxyfs.go +++ /dev/null @@ -1,519 +0,0 @@ -package proxyfs - -import ( - "fmt" - "io" - "net/url" - "strings" - "sync/atomic" - "time" - - agfs "github.com/c4pt0r/agfs/agfs-sdk/go" - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" -) - -const ( - PluginName = "proxyfs" // Name of this plugin -) - -// Convert SDK FileInfo to server FileInfo -func convertFileInfo(src agfs.FileInfo) filesystem.FileInfo { - return filesystem.FileInfo{ - Name: src.Name, - Size: src.Size, - Mode: src.Mode, - ModTime: src.ModTime, - IsDir: src.IsDir, - Meta: filesystem.MetaData{ - Name: src.Meta.Name, - Type: src.Meta.Type, - Content: src.Meta.Content, - }, - } -} - -// Convert SDK FileInfo slice to server FileInfo slice -func convertFileInfos(src []agfs.FileInfo) []filesystem.FileInfo { - result := make([]filesystem.FileInfo, len(src)) - for i, f := range src { - result[i] = convertFileInfo(f) - } - return result -} - -// ProxyFS implements filesystem.FileSystem by proxying to a remote AGFS HTTP API -// All file system operations are transparently forwarded to the remote server -type ProxyFS struct { - client atomic.Pointer[agfs.Client] - pluginName string - baseURL string // Store base URL for reload -} - -// NewProxyFS creates a new ProxyFS that redirects to a remote AGFS server -// baseURL should include the API version, e.g., "http://localhost:8080/api/v1" -func NewProxyFS(baseURL string, pluginName string) *ProxyFS { - p := &ProxyFS{ - pluginName: pluginName, - baseURL: baseURL, - } - p.client.Store(agfs.NewClient(baseURL)) - return p -} - -// Reload recreates the HTTP client, useful for refreshing connections -func (p *ProxyFS) Reload() error { - // Create a new client to refresh the connection - newClient := agfs.NewClient(p.baseURL) - - // Test the new connection - if err := newClient.Health(); err != nil { - return fmt.Errorf("failed to connect after reload: %w", err) - } - - // Atomically replace the client - p.client.Store(newClient) - - return nil -} - -func (p *ProxyFS) Create(path string) error { - return p.client.Load().Create(path) -} - -func (p *ProxyFS) Mkdir(path string, perm uint32) error { - return p.client.Load().Mkdir(path, perm) -} - -func (p *ProxyFS) Remove(path string) error { - return p.client.Load().Remove(path) -} - -func (p *ProxyFS) RemoveAll(path string) error { - return p.client.Load().RemoveAll(path) -} - -func (p *ProxyFS) Read(path string, offset int64, size int64) ([]byte, error) { - // Special handling for /reload - if path == "/reload" { - data := []byte("Write to this file to reload the proxy connection\n") - return plugin.ApplyRangeRead(data, offset, size) - } - return p.client.Load().Read(path, offset, size) -} - -func (p *ProxyFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - // Special handling for /reload - trigger hot reload - if path == "/reload" { - if err := p.Reload(); err != nil { - return 0, fmt.Errorf("reload failed: %w", err) - } - return int64(len(data)), nil - } - // Note: SDK client doesn't support new Write signature yet - // For now, we ignore offset and flags and use the legacy method - // TODO: Update SDK to support new Write signature - _, err := p.client.Load().Write(path, data) - if err != nil { - return 0, err - } - return int64(len(data)), nil -} - -func (p *ProxyFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - sdkFiles, err := p.client.Load().ReadDir(path) - if err != nil { - return nil, err - } - - files := convertFileInfos(sdkFiles) - - // Add /reload virtual file to root directory listing - if path == "/" { - reloadFile := filesystem.FileInfo{ - Name: "reload", - Size: 0, - Mode: 0o200, // write-only - ModTime: files[0].ModTime, // Use same time as first file - IsDir: false, - Meta: filesystem.MetaData{ - Type: "control", - Content: map[string]string{ - "description": "Write to this file to reload proxy connection", - }, - }, - } - files = append(files, reloadFile) - } - - return files, nil -} - -func (p *ProxyFS) Stat(path string) (*filesystem.FileInfo, error) { - // Special handling for /reload - if path == "/reload" { - return &filesystem.FileInfo{ - Name: "reload", - Size: 0, - Mode: 0o200, // write-only - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Type: "control", - Content: map[string]string{ - "description": "Write to this file to reload proxy connection", - "remote-url": p.baseURL, - }, - }, - }, nil - } - - // Get stat from remote - sdkStat, err := p.client.Load().Stat(path) - if err != nil { - return nil, err - } - - // Convert SDK FileInfo to server FileInfo - stat := convertFileInfo(*sdkStat) - - // Add remote URL to metadata - if stat.Meta.Content == nil { - stat.Meta.Content = make(map[string]string) - } - stat.Meta.Content["remote-url"] = p.baseURL - - return &stat, nil -} - -func (p *ProxyFS) Rename(oldPath, newPath string) error { - return p.client.Load().Rename(oldPath, newPath) -} - -func (p *ProxyFS) Chmod(path string, mode uint32) error { - return p.client.Load().Chmod(path, mode) -} - -func (p *ProxyFS) Open(path string) (io.ReadCloser, error) { - data, err := p.client.Load().Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(io.Reader(newBytesReader(data))), nil -} - -func (p *ProxyFS) OpenWrite(path string) (io.WriteCloser, error) { - return filesystem.NewBufferedWriter(path, p.Write), nil -} - -// OpenStream implements filesystem.Streamer interface -func (p *ProxyFS) OpenStream(path string) (filesystem.StreamReader, error) { - // Use the client's ReadStream to get a streaming connection - streamReader, err := p.client.Load().ReadStream(path) - if err != nil { - return nil, err - } - - // Return a ProxyStreamReader that implements filesystem.StreamReader - return &ProxyStreamReader{ - reader: streamReader, - path: path, - buf: make([]byte, 64*1024), // 64KB buffer for chunked reads - }, nil -} - -// GetStream returns a streaming reader for remote streamfs files -// Deprecated: Use OpenStream instead -func (p *ProxyFS) GetStream(path string) (interface{}, error) { - // Use the client's ReadStream to get a streaming connection - streamReader, err := p.client.Load().ReadStream(path) - if err != nil { - return nil, err - } - - // Wrap the io.ReadCloser in a ProxyStream for backward compatibility - return &ProxyStream{ - reader: streamReader, - path: path, - }, nil -} - -// ProxyStreamReader adapts an io.ReadCloser to filesystem.StreamReader -// It reads chunks from the remote stream with timeout support -type ProxyStreamReader struct { - reader io.ReadCloser - path string - buf []byte // Buffer for reading chunks -} - -// ReadChunk implements filesystem.StreamReader -func (psr *ProxyStreamReader) ReadChunk(timeout time.Duration) ([]byte, bool, error) { - // Set read deadline if possible - // Note: HTTP response bodies don't support deadlines, so timeout is best-effort - - // Read a chunk from the stream - n, err := psr.reader.Read(psr.buf) - - if n > 0 { - // Make a copy of the data to return - chunk := make([]byte, n) - copy(chunk, psr.buf[:n]) - return chunk, false, nil - } - - if err == io.EOF { - return nil, true, io.EOF - } - - if err != nil { - return nil, false, err - } - - // No data and no error - unlikely but handle it - return nil, false, fmt.Errorf("read timeout") -} - -// Close implements filesystem.StreamReader -func (psr *ProxyStreamReader) Close() error { - return psr.reader.Close() -} - -// ProxyStream wraps an io.ReadCloser to provide streaming functionality -// Deprecated: Used for backward compatibility with old GetStream interface -type ProxyStream struct { - reader io.ReadCloser - path string -} - -// Read implements io.Reader -func (ps *ProxyStream) Read(p []byte) (n int, err error) { - return ps.reader.Read(p) -} - -// Close implements io.Closer -func (ps *ProxyStream) Close() error { - return ps.reader.Close() -} - -// bytesReader wraps a byte slice to implement io.Reader -type bytesReader struct { - data []byte - pos int -} - -func newBytesReader(data []byte) *bytesReader { - return &bytesReader{data: data, pos: 0} -} - -func (r *bytesReader) Read(p []byte) (n int, err error) { - if r.pos >= len(r.data) { - return 0, io.EOF - } - n = copy(p, r.data[r.pos:]) - r.pos += n - return n, nil -} - -// ProxyFSPlugin wraps ProxyFS as a plugin that can be mounted in AGFS -// It enables remote file system access through the AGFS plugin system -type ProxyFSPlugin struct { - fs *ProxyFS - baseURL string -} - -// NewProxyFSPlugin creates a new ProxyFS plugin -// baseURL should be the full API endpoint, e.g., "http://remote-server:8080/api/v1" -func NewProxyFSPlugin(baseURL string) *ProxyFSPlugin { - return &ProxyFSPlugin{ - baseURL: baseURL, - fs: NewProxyFS(baseURL, PluginName), - } -} - -func (p *ProxyFSPlugin) Name() string { - return PluginName -} - -func (p *ProxyFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"base_url", "mount_path"} - if cfg != nil { - for key := range cfg { - found := false - for _, allowed := range allowedKeys { - if key == allowed { - found = true - break - } - } - if !found { - return fmt.Errorf("unknown configuration parameter: %s (allowed: %v)", key, allowedKeys) - } - } - } - - // base_url is required (either from constructor or config) - baseURL := p.baseURL - if cfg != nil { - if u, ok := cfg["base_url"].(string); ok && u != "" { - baseURL = u - } - } - - if baseURL == "" { - return fmt.Errorf("base_url is required in configuration") - } - - // Validate URL format - if _, err := url.Parse(baseURL); err != nil { - return fmt.Errorf("invalid base_url format: %w", err) - } - - return nil -} - -func (p *ProxyFSPlugin) Initialize(config map[string]interface{}) error { - // Override base URL if provided in config - // Expected config: {"base_url": "http://remote-server:8080/api/v1"} - if config != nil { - if url, ok := config["base_url"].(string); ok && url != "" { - p.baseURL = url - p.fs = NewProxyFS(url, PluginName) - } - } - - // Validate that we have a base URL - if p.baseURL == "" { - return fmt.Errorf("base_url is required in configuration") - } - - // Validate that the base URL is properly formatted - // Check for protocol separator to catch common mistakes like "http:" instead of "http://host" - if !strings.Contains(p.baseURL, "://") { - return fmt.Errorf("invalid base_url format: %s (expected format: http://hostname:port or http://hostname:port/api/v1). Did you forget to quote the URL?", p.baseURL) - } - - // Test connection to remote server with health check - if err := p.fs.client.Load().Health(); err != nil { - return fmt.Errorf("failed to connect to remote AGFS server at %s: %w", p.baseURL, err) - } - - return nil -} - -func (p *ProxyFSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *ProxyFSPlugin) GetReadme() string { - return `ProxyFS Plugin - Remote AGFS Proxy - -This plugin proxies all file system operations to a remote AGFS HTTP API server. - -FEATURES: - - Transparent proxying of all file system operations - - Full compatibility with AGFS HTTP API - - Connects to remote AGFS servers - - Supports all standard file operations - - Supports streaming operations (cat --stream) - - Transparent proxying of remote streamfs - - Implements filesystem.Streamer interface - -CONFIGURATION: - base_url: URL of the remote AGFS server (e.g., "http://remote:8080/api/v1") - -HOT RELOAD: - ProxyFS provides a special /reload file for hot-reloading the connection: - - Echo to /reload to refresh the proxy connection: - echo '' > /proxyfs/reload - - This is useful when: - - Remote server was restarted - - Network connection was interrupted - - Need to refresh connection pool - -USAGE: - All standard file operations are proxied to the remote server: - - Create a file: - touch /path/to/file - - Write to a file: - echo "content" > /path/to/file - - Read a file: - cat /path/to/file - - Create a directory: - mkdir /path/to/dir - - List directory: - ls /path/to/dir - - Remove file/directory: - rm /path/to/file - rm -r /path/to/dir - - Move/rename: - mv /old/path /new/path - - Change permissions: - chmod 755 /path/to/file - -STREAMING SUPPORT: - ProxyFS transparently proxies streaming operations to remote AGFS servers. - - Access remote streamfs: - p cat --stream /proxyfs/remote/streamfs/video | ffplay - - - Write to remote streamfs: - cat file.mp4 | p write --stream /proxyfs/remote/streamfs/video - - All streaming features from remote streamfs are fully supported: - - Real-time data streaming - - Ring buffer with historical data - - Multiple concurrent readers (fanout) - - Persistent connections (no timeout disconnect) - -EXAMPLES: - # Standard file operations - agfs:/> mkdir /proxyfs/remote/data - agfs:/> echo "hello" > /proxyfs/remote/data/file.txt - agfs:/> cat /proxyfs/remote/data/file.txt - hello - agfs:/> ls /proxyfs/remote/data - - # Streaming operations (outside REPL) - $ p cat --stream /proxyfs/remote/streamfs/logs - $ cat video.mp4 | p write --stream /proxyfs/remote/streamfs/video - -USE CASES: - - Connect to remote AGFS instances - - Federation of multiple AGFS servers - - Access remote services through local mount points - - Distributed file system scenarios - - Stream video/audio from remote streamfs - - Remote real-time data streaming - -` -} - -func (p *ProxyFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "base_url", - Type: "string", - Required: true, - Default: "", - Description: "Base URL of the remote AGFS server (e.g., http://localhost:8080)", - }, - } -} - -func (p *ProxyFSPlugin) Shutdown() error { - return nil -} - -// Ensure ProxyFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*ProxyFSPlugin)(nil) \ No newline at end of file diff --git a/third_party/agfs/agfs-server/pkg/plugins/queuefs/README.md b/third_party/agfs/agfs-server/pkg/plugins/queuefs/README.md deleted file mode 100644 index a9ac53149..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/queuefs/README.md +++ /dev/null @@ -1,58 +0,0 @@ -QueueFS Plugin - Message Queue Service - -This plugin provides a message queue service through a file system interface. - -DYNAMIC MOUNTING WITH AGFS SHELL: - - Interactive shell: - agfs:/> mount queuefs /queue - agfs:/> mount queuefs /tasks - agfs:/> mount queuefs /messages - - Direct command: - uv run agfs mount queuefs /queue - uv run agfs mount queuefs /jobs - -CONFIGURATION PARAMETERS: - - None required - QueueFS works with default settings - -USAGE: - Enqueue a message: - echo "your message" > /enqueue - - Dequeue a message: - cat /dequeue - - Peek at next message (without removing): - cat /peek - - Get queue size: - cat /size - - Clear the queue: - echo "" > /clear - -FILES: - /enqueue - Write-only file to enqueue messages - /dequeue - Read-only file to dequeue messages - /peek - Read-only file to peek at next message - /size - Read-only file showing queue size - /clear - Write-only file to clear all messages - /README - This file - -EXAMPLES: - # Enqueue a message - agfs:/> echo "task-123" > /queuefs/enqueue - - # Check queue size - agfs:/> cat /queuefs/size - 1 - - # Dequeue a message - agfs:/> cat /queuefs/dequeue - {"id":"...","data":"task-123","timestamp":"..."} - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/queuefs/backend.go b/third_party/agfs/agfs-server/pkg/plugins/queuefs/backend.go deleted file mode 100644 index c20fdc662..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/queuefs/backend.go +++ /dev/null @@ -1,696 +0,0 @@ -package queuefs - -import ( - "database/sql" - "encoding/json" - "fmt" - "sync" - "time" - - log "github.com/sirupsen/logrus" -) - -// QueueBackend defines the interface for queue storage backends -type QueueBackend interface { - // Initialize initializes the backend with configuration - Initialize(config map[string]interface{}) error - - // Close closes the backend connection - Close() error - - // GetType returns the backend type name - GetType() string - - // Enqueue adds a message to a queue - Enqueue(queueName string, msg QueueMessage) error - - // Dequeue marks the first pending message as 'processing' and returns it. - // Call Ack after successful processing to permanently delete the message. - Dequeue(queueName string) (QueueMessage, bool, error) - - // Ack permanently deletes a message that has been successfully processed. - Ack(queueName string, messageID string) error - - // RecoverStale resets messages stuck in 'processing' state back to 'pending'. - // staleSec: minimum age in seconds; pass 0 to reset all processing messages. - // Returns the number of messages recovered. - RecoverStale(staleSec int64) (int, error) - - // Peek returns the first message without removing it - Peek(queueName string) (QueueMessage, bool, error) - - // Size returns the number of messages in a queue - Size(queueName string) (int, error) - - // Clear removes all messages from a queue - Clear(queueName string) error - - // ListQueues returns all queue names (for directory listing) - ListQueues(prefix string) ([]string, error) - - // GetLastEnqueueTime returns the timestamp of the last enqueued message - GetLastEnqueueTime(queueName string) (time.Time, error) - - // RemoveQueue removes all messages for a queue and its nested queues - RemoveQueue(queueName string) error - - // CreateQueue creates an empty queue (for mkdir support) - CreateQueue(queueName string) error - - // QueueExists checks if a queue exists (even if empty) - QueueExists(queueName string) (bool, error) -} - -// MemoryBackend implements QueueBackend using in-memory storage -type MemoryBackend struct { - queues map[string]*Queue -} - -func NewMemoryBackend() *MemoryBackend { - return &MemoryBackend{ - queues: make(map[string]*Queue), - } -} - -func (b *MemoryBackend) Initialize(config map[string]interface{}) error { - // No initialization needed for memory backend - return nil -} - -func (b *MemoryBackend) Close() error { - b.queues = nil - return nil -} - -func (b *MemoryBackend) GetType() string { - return "memory" -} - -func (b *MemoryBackend) getOrCreateQueue(queueName string) *Queue { - if queue, exists := b.queues[queueName]; exists { - return queue - } - queue := &Queue{ - messages: []QueueMessage{}, - lastEnqueueTime: time.Time{}, - } - b.queues[queueName] = queue - return queue -} - -func (b *MemoryBackend) Enqueue(queueName string, msg QueueMessage) error { - queue := b.getOrCreateQueue(queueName) - queue.mu.Lock() - defer queue.mu.Unlock() - - queue.messages = append(queue.messages, msg) - - // Update lastEnqueueTime - if msg.Timestamp.After(queue.lastEnqueueTime) { - queue.lastEnqueueTime = msg.Timestamp - } else { - queue.lastEnqueueTime = queue.lastEnqueueTime.Add(1 * time.Nanosecond) - } - - return nil -} - -func (b *MemoryBackend) Dequeue(queueName string) (QueueMessage, bool, error) { - queue, exists := b.queues[queueName] - if !exists { - return QueueMessage{}, false, nil - } - - queue.mu.Lock() - defer queue.mu.Unlock() - - if len(queue.messages) == 0 { - return QueueMessage{}, false, nil - } - - msg := queue.messages[0] - queue.messages = queue.messages[1:] - return msg, true, nil -} - -// Ack is a no-op for the memory backend (messages are already removed on Dequeue). -func (b *MemoryBackend) Ack(queueName string, messageID string) error { - return nil -} - -// RecoverStale is a no-op for the memory backend (no persistence across restarts). -func (b *MemoryBackend) RecoverStale(staleSec int64) (int, error) { - return 0, nil -} - -func (b *MemoryBackend) Peek(queueName string) (QueueMessage, bool, error) { - queue, exists := b.queues[queueName] - if !exists { - return QueueMessage{}, false, nil - } - - queue.mu.Lock() - defer queue.mu.Unlock() - - if len(queue.messages) == 0 { - return QueueMessage{}, false, nil - } - - return queue.messages[0], true, nil -} - -func (b *MemoryBackend) Size(queueName string) (int, error) { - queue, exists := b.queues[queueName] - if !exists { - return 0, nil - } - - queue.mu.Lock() - defer queue.mu.Unlock() - - return len(queue.messages), nil -} - -func (b *MemoryBackend) Clear(queueName string) error { - queue, exists := b.queues[queueName] - if !exists { - return nil - } - - queue.mu.Lock() - defer queue.mu.Unlock() - - queue.messages = []QueueMessage{} - queue.lastEnqueueTime = time.Time{} - return nil -} - -func (b *MemoryBackend) ListQueues(prefix string) ([]string, error) { - var queues []string - for qName := range b.queues { - if prefix == "" || qName == prefix || len(qName) > len(prefix) && qName[:len(prefix)+1] == prefix+"/" { - queues = append(queues, qName) - } - } - return queues, nil -} - -func (b *MemoryBackend) GetLastEnqueueTime(queueName string) (time.Time, error) { - queue, exists := b.queues[queueName] - if !exists { - return time.Time{}, nil - } - - queue.mu.Lock() - defer queue.mu.Unlock() - - return queue.lastEnqueueTime, nil -} - -func (b *MemoryBackend) RemoveQueue(queueName string) error { - // Remove the queue and all nested queues - if queueName == "" { - b.queues = make(map[string]*Queue) - return nil - } - - delete(b.queues, queueName) - - // Remove nested queues - prefix := queueName + "/" - for qName := range b.queues { - if len(qName) > len(prefix) && qName[:len(prefix)] == prefix { - delete(b.queues, qName) - } - } - - return nil -} - -func (b *MemoryBackend) CreateQueue(queueName string) error { - b.getOrCreateQueue(queueName) - return nil -} - -func (b *MemoryBackend) QueueExists(queueName string) (bool, error) { - _, exists := b.queues[queueName] - return exists, nil -} - -// TiDBBackend implements QueueBackend using TiDB database -type TiDBBackend struct { - db *sql.DB - backend DBBackend - backendType string - tableCache map[string]string // queueName -> tableName cache - cacheMu sync.RWMutex // protects tableCache -} - -func NewTiDBBackend() *TiDBBackend { - return &TiDBBackend{ - tableCache: make(map[string]string), - } -} - -func (b *TiDBBackend) Initialize(config map[string]interface{}) error { - // Store backend type from config - backendType := "memory" // default - if val, ok := config["backend"]; ok { - if strVal, ok := val.(string); ok { - backendType = strVal - } - } - b.backendType = backendType - - // Create database backend - backend, err := CreateBackend(config) - if err != nil { - return fmt.Errorf("failed to create backend: %w", err) - } - b.backend = backend - - // Open database connection - db, err := backend.Open(config) - if err != nil { - return fmt.Errorf("failed to open database: %w", err) - } - b.db = db - - // Initialize schema - for _, sqlStmt := range backend.GetInitSQL() { - if _, err := db.Exec(sqlStmt); err != nil { - db.Close() - return fmt.Errorf("failed to initialize schema: %w", err) - } - } - - return nil -} - -func (b *TiDBBackend) Close() error { - if b.db != nil { - return b.db.Close() - } - return nil -} - -func (b *TiDBBackend) GetType() string { - return b.backendType -} - -// getTableName retrieves the table name for a queue, using cache when possible -// If forceRefresh is true, it will bypass the cache and query from database -func (b *TiDBBackend) getTableName(queueName string, forceRefresh bool) (string, error) { - // Try to get from cache first (unless force refresh) - if !forceRefresh { - b.cacheMu.RLock() - if tableName, exists := b.tableCache[queueName]; exists { - b.cacheMu.RUnlock() - return tableName, nil - } - b.cacheMu.RUnlock() - } - - // Query from database - var tableName string - err := b.db.QueryRow( - "SELECT table_name FROM queuefs_registry WHERE queue_name = ?", - queueName, - ).Scan(&tableName) - - if err != nil { - return "", err - } - - // Update cache - b.cacheMu.Lock() - b.tableCache[queueName] = tableName - b.cacheMu.Unlock() - - return tableName, nil -} - -// invalidateCache removes a queue from the cache -func (b *TiDBBackend) invalidateCache(queueName string) { - b.cacheMu.Lock() - delete(b.tableCache, queueName) - b.cacheMu.Unlock() -} - -func (b *TiDBBackend) Enqueue(queueName string, msg QueueMessage) error { - msgData, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - // Get table name from cache (lazy loading) - tableName, err := b.getTableName(queueName, false) - if err == sql.ErrNoRows { - return fmt.Errorf("queue does not exist: %s (create it with mkdir first)", queueName) - } else if err != nil { - return fmt.Errorf("failed to get queue table name: %w", err) - } - - // Insert message into queue table - insertSQL := fmt.Sprintf( - "INSERT INTO %s (message_id, data, timestamp, deleted) VALUES (?, ?, ?, 0)", - tableName, - ) - _, err = b.db.Exec(insertSQL, msg.ID, string(msgData), msg.Timestamp.Unix()) - if err != nil { - return fmt.Errorf("failed to enqueue message: %w", err) - } - - return nil -} - -// Ack is not yet implemented for TiDB backend (messages are already soft-deleted on Dequeue). -func (b *TiDBBackend) Ack(queueName string, messageID string) error { - return nil -} - -// RecoverStale is not yet implemented for TiDB backend. -func (b *TiDBBackend) RecoverStale(staleSec int64) (int, error) { - return 0, nil -} - -func (b *TiDBBackend) Dequeue(queueName string) (QueueMessage, bool, error) { - // Get table name from cache (lazy loading) - tableName, err := b.getTableName(queueName, false) - if err == sql.ErrNoRows { - return QueueMessage{}, false, nil - } else if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to get queue table name: %w", err) - } - - // Start transaction - tx, err := b.db.Begin() - if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to start transaction: %w", err) - } - defer tx.Rollback() - - // Get and mark the first non-deleted message as deleted in a single atomic operation - // Using FOR UPDATE SKIP LOCKED to skip rows locked by other transactions for better concurrency - var id int64 - var data string - - querySQL := fmt.Sprintf( - "SELECT id, data FROM %s WHERE deleted = 0 ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED", - tableName, - ) - err = tx.QueryRow(querySQL).Scan(&id, &data) - - if err == sql.ErrNoRows { - return QueueMessage{}, false, nil - } else if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to query message: %w", err) - } - - // Mark the message as deleted - updateSQL := fmt.Sprintf( - "UPDATE %s SET deleted = 1, deleted_at = CURRENT_TIMESTAMP WHERE id = ?", - tableName, - ) - _, err = tx.Exec(updateSQL, id) - if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to mark message as deleted: %w", err) - } - - // Commit transaction - if err := tx.Commit(); err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to commit transaction: %w", err) - } - - // Unmarshal message - var msg QueueMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to unmarshal message: %w", err) - } - - return msg, true, nil -} - -func (b *TiDBBackend) Peek(queueName string) (QueueMessage, bool, error) { - // Get table name from cache (lazy loading) - tableName, err := b.getTableName(queueName, false) - if err == sql.ErrNoRows { - return QueueMessage{}, false, nil - } else if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to get queue table name: %w", err) - } - - var data string - querySQL := fmt.Sprintf( - "SELECT data FROM %s WHERE deleted = 0 ORDER BY id LIMIT 1", - tableName, - ) - err = b.db.QueryRow(querySQL).Scan(&data) - - if err == sql.ErrNoRows { - return QueueMessage{}, false, nil - } else if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to peek message: %w", err) - } - - // Unmarshal message - var msg QueueMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to unmarshal message: %w", err) - } - - return msg, true, nil -} - -func (b *TiDBBackend) Size(queueName string) (int, error) { - // Get table name from cache (lazy loading) - tableName, err := b.getTableName(queueName, false) - if err == sql.ErrNoRows { - return 0, nil - } else if err != nil { - return 0, fmt.Errorf("failed to get queue table name: %w", err) - } - - var count int - querySQL := fmt.Sprintf( - "SELECT COUNT(*) FROM %s WHERE deleted = 0", - tableName, - ) - err = b.db.QueryRow(querySQL).Scan(&count) - if err != nil { - return 0, fmt.Errorf("failed to get queue size: %w", err) - } - return count, nil -} - -func (b *TiDBBackend) Clear(queueName string) error { - // Get table name from cache (lazy loading) - tableName, err := b.getTableName(queueName, false) - if err == sql.ErrNoRows { - return nil // Queue doesn't exist, nothing to clear - } else if err != nil { - return fmt.Errorf("failed to get queue table name: %w", err) - } - - // Clear all messages (both deleted and non-deleted) - deleteSQL := fmt.Sprintf("DELETE FROM %s", tableName) - _, err = b.db.Exec(deleteSQL) - if err != nil { - return fmt.Errorf("failed to clear queue: %w", err) - } - return nil -} - -func (b *TiDBBackend) ListQueues(prefix string) ([]string, error) { - // Query from registry table to include all queues - var query string - var args []interface{} - - if prefix == "" { - query = "SELECT queue_name FROM queuefs_registry" - } else { - query = "SELECT queue_name FROM queuefs_registry WHERE queue_name = ? OR queue_name LIKE ?" - args = []interface{}{prefix, prefix + "/%"} - } - - rows, err := b.db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("failed to list queues: %w", err) - } - defer rows.Close() - - var queues []string - for rows.Next() { - var qName string - if err := rows.Scan(&qName); err != nil { - return nil, fmt.Errorf("failed to scan queue name: %w", err) - } - queues = append(queues, qName) - } - - return queues, nil -} - -func (b *TiDBBackend) GetLastEnqueueTime(queueName string) (time.Time, error) { - // Get table name from cache (lazy loading) - tableName, err := b.getTableName(queueName, false) - if err == sql.ErrNoRows { - return time.Time{}, nil - } else if err != nil { - return time.Time{}, fmt.Errorf("failed to get queue table name: %w", err) - } - - var timestamp int64 - querySQL := fmt.Sprintf( - "SELECT MAX(timestamp) FROM %s WHERE deleted = 0", - tableName, - ) - err = b.db.QueryRow(querySQL).Scan(×tamp) - - if err == sql.ErrNoRows || timestamp == 0 { - return time.Time{}, nil - } else if err != nil { - return time.Time{}, fmt.Errorf("failed to get last enqueue time: %w", err) - } - - return time.Unix(timestamp, 0), nil -} - -func (b *TiDBBackend) RemoveQueue(queueName string) error { - if queueName == "" { - // Remove all queues: drop all queue tables and clear registry - rows, err := b.db.Query("SELECT queue_name, table_name FROM queuefs_registry") - if err != nil { - return fmt.Errorf("failed to list queues: %w", err) - } - defer rows.Close() - - var queuesToDelete []struct { - queueName string - tableName string - } - - for rows.Next() { - var qName, tName string - if err := rows.Scan(&qName, &tName); err != nil { - return fmt.Errorf("failed to scan queue: %w", err) - } - queuesToDelete = append(queuesToDelete, struct { - queueName string - tableName string - }{qName, tName}) - } - - // Drop all tables and clear cache - for _, q := range queuesToDelete { - dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s", q.tableName) - if _, err := b.db.Exec(dropSQL); err != nil { - log.Warnf("[queuefs] Failed to drop table '%s': %v", q.tableName, err) - } - } - - // Clear cache completely - b.cacheMu.Lock() - b.tableCache = make(map[string]string) - b.cacheMu.Unlock() - - // Clear registry - _, err = b.db.Exec("DELETE FROM queuefs_registry") - return err - } - - // Remove queue and nested queues - rows, err := b.db.Query( - "SELECT queue_name, table_name FROM queuefs_registry WHERE queue_name = ? OR queue_name LIKE ?", - queueName, queueName+"/%", - ) - if err != nil { - return fmt.Errorf("failed to query queues: %w", err) - } - defer rows.Close() - - var queuesToDelete []struct { - queueName string - tableName string - } - - for rows.Next() { - var qName, tName string - if err := rows.Scan(&qName, &tName); err != nil { - return fmt.Errorf("failed to scan queue: %w", err) - } - queuesToDelete = append(queuesToDelete, struct { - queueName string - tableName string - }{qName, tName}) - } - - // Drop tables and invalidate cache - for _, q := range queuesToDelete { - dropSQL := fmt.Sprintf("DROP TABLE IF EXISTS %s", q.tableName) - if _, err := b.db.Exec(dropSQL); err != nil { - log.Warnf("[queuefs] Failed to drop table '%s': %v", q.tableName, err) - } else { - log.Infof("[queuefs] Dropped queue table '%s' for queue '%s'", q.tableName, q.queueName) - } - // Invalidate cache for this queue - b.invalidateCache(q.queueName) - } - - // Remove from registry - _, err = b.db.Exec( - "DELETE FROM queuefs_registry WHERE queue_name = ? OR queue_name LIKE ?", - queueName, queueName+"/%", - ) - return err -} - -func (b *TiDBBackend) CreateQueue(queueName string) error { - // Generate table name - tableName := sanitizeTableName(queueName) - - // Create the queue table - createTableSQL := getCreateTableSQL(tableName) - if _, err := b.db.Exec(createTableSQL); err != nil { - return fmt.Errorf("failed to create queue table: %w", err) - } - - // Register in queuefs_registry - _, err := b.db.Exec( - "INSERT IGNORE INTO queuefs_registry (queue_name, table_name) VALUES (?, ?)", - queueName, tableName, - ) - if err != nil { - return fmt.Errorf("failed to register queue: %w", err) - } - - // Update cache - b.cacheMu.Lock() - b.tableCache[queueName] = tableName - b.cacheMu.Unlock() - - log.Infof("[queuefs] Created queue table '%s' for queue '%s'", tableName, queueName) - return nil -} - -func (b *TiDBBackend) QueueExists(queueName string) (bool, error) { - // Check cache first - b.cacheMu.RLock() - _, exists := b.tableCache[queueName] - b.cacheMu.RUnlock() - - if exists { - return true, nil - } - - // If not in cache, query database - var count int - err := b.db.QueryRow( - "SELECT COUNT(*) FROM queuefs_registry WHERE queue_name = ?", - queueName, - ).Scan(&count) - if err != nil { - return false, fmt.Errorf("failed to check queue existence: %w", err) - } - return count > 0, nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/queuefs/db_backend.go b/third_party/agfs/agfs-server/pkg/plugins/queuefs/db_backend.go deleted file mode 100644 index 9639531c0..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/queuefs/db_backend.go +++ /dev/null @@ -1,276 +0,0 @@ -package queuefs - -import ( - "crypto/tls" - "database/sql" - "fmt" - "regexp" - "strings" - - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - "github.com/go-sql-driver/mysql" - _ "github.com/go-sql-driver/mysql" // MySQL/TiDB driver - _ "github.com/mattn/go-sqlite3" // SQLite driver - log "github.com/sirupsen/logrus" -) - -// DBBackend defines the interface for database operations -type DBBackend interface { - // Open opens a connection to the database - Open(cfg map[string]interface{}) (*sql.DB, error) - - // GetInitSQL returns the SQL statements to initialize the schema - GetInitSQL() []string - - // GetDriverName returns the driver name - GetDriverName() string -} - -// SQLiteDBBackend implements DBBackend for SQLite -type SQLiteDBBackend struct{} - -func NewSQLiteDBBackend() *SQLiteDBBackend { - return &SQLiteDBBackend{} -} - -func (b *SQLiteDBBackend) GetDriverName() string { - return "sqlite3" -} - -func (b *SQLiteDBBackend) Open(cfg map[string]interface{}) (*sql.DB, error) { - dbPath := config.GetStringConfig(cfg, "db_path", "queue.db") - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, fmt.Errorf("failed to open SQLite database: %w", err) - } - - // Enable WAL mode for better concurrency - if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { - db.Close() - return nil, fmt.Errorf("failed to enable WAL mode: %w", err) - } - - return db, nil -} - -func (b *SQLiteDBBackend) GetInitSQL() []string { - return []string{ - // Queue metadata table to track all queues (including empty ones) - `CREATE TABLE IF NOT EXISTS queue_metadata ( - queue_name TEXT PRIMARY KEY, - created_at INTEGER DEFAULT (strftime('%s', 'now')), - last_updated INTEGER DEFAULT (strftime('%s', 'now')) - )`, - // Queue messages table - // status: 'pending' (waiting) | 'processing' (dequeued, not yet acked) - // processing_started_at: Unix timestamp when dequeued; NULL if pending - `CREATE TABLE IF NOT EXISTS queue_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - queue_name TEXT NOT NULL, - message_id TEXT NOT NULL, - data TEXT NOT NULL, - timestamp INTEGER NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - processing_started_at INTEGER, - created_at INTEGER DEFAULT (strftime('%s', 'now')) - )`, - `CREATE INDEX IF NOT EXISTS idx_queue_name ON queue_messages(queue_name)`, - `CREATE INDEX IF NOT EXISTS idx_queue_order ON queue_messages(queue_name, id)`, - `CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_messages(queue_name, status, id)`, - `CREATE INDEX IF NOT EXISTS idx_queue_message_id ON queue_messages(queue_name, message_id)`, - } -} - -// TiDBDBBackend implements DBBackend for TiDB -type TiDBDBBackend struct{} - -func NewTiDBDBBackend() *TiDBDBBackend { - return &TiDBDBBackend{} -} - -func (b *TiDBDBBackend) GetDriverName() string { - return "mysql" -} - -func (b *TiDBDBBackend) Open(cfg map[string]interface{}) (*sql.DB, error) { - // Check if DSN contains tls parameter - dsnStr := config.GetStringConfig(cfg, "dsn", "") - dsnHasTLS := strings.Contains(dsnStr, "tls=") - - // Register TLS configuration if needed - enableTLS := config.GetBoolConfig(cfg, "enable_tls", false) || dsnHasTLS - tlsConfigName := "tidb-queuefs" - - if enableTLS { - // Get TLS configuration - serverName := config.GetStringConfig(cfg, "tls_server_name", "") - - // If no explicit server name, try to extract from DSN or host - if serverName == "" { - if dsnStr != "" { - // Extract host from DSN - re := regexp.MustCompile(`@tcp\(([^:]+):\d+\)`) - if matches := re.FindStringSubmatch(dsnStr); len(matches) > 1 { - serverName = matches[1] - } - } else { - serverName = config.GetStringConfig(cfg, "host", "") - } - } - - skipVerify := config.GetBoolConfig(cfg, "tls_skip_verify", false) - - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - if serverName != "" { - tlsConfig.ServerName = serverName - } - - if skipVerify { - tlsConfig.InsecureSkipVerify = true - log.Warn("[queuefs] TLS certificate verification is disabled (insecure)") - } - - // Register TLS config - if err := mysql.RegisterTLSConfig(tlsConfigName, tlsConfig); err != nil { - log.Warnf("[queuefs] Failed to register TLS config (may already exist): %v", err) - } - } - - // Build DSN - var dsn string - - if dsnStr != "" { - dsn = dsnStr - } else { - user := config.GetStringConfig(cfg, "user", "root") - password := config.GetStringConfig(cfg, "password", "") - host := config.GetStringConfig(cfg, "host", "127.0.0.1") - port := config.GetStringConfig(cfg, "port", "4000") - database := config.GetStringConfig(cfg, "database", "queuedb") - - if password != "" { - dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, password, host, port, database) - } else { - dsn = fmt.Sprintf("%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, host, port, database) - } - - if enableTLS { - dsn += fmt.Sprintf("&tls=%s", tlsConfigName) - } - } - - log.Infof("[queuefs] Connecting to TiDB (TLS: %v)", enableTLS) - - // Extract database name - dbName := extractDatabaseName(dsn, config.GetStringConfig(cfg, "database", "")) - - // Create database if needed - if dbName != "" { - dsnWithoutDB := removeDatabaseFromDSN(dsn) - if dsnWithoutDB != dsn { - tempDB, err := sql.Open("mysql", dsnWithoutDB) - if err == nil { - defer tempDB.Close() - _, err = tempDB.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", dbName)) - if err != nil { - log.Warnf("[queuefs] Failed to create database '%s': %v", dbName, err) - } else { - log.Infof("[queuefs] Database '%s' created or already exists", dbName) - } - } - } - } - - db, err := sql.Open("mysql", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open TiDB database: %w", err) - } - - // Set connection pool parameters - db.SetMaxOpenConns(100) - db.SetMaxIdleConns(10) - - // Test connection - if err := db.Ping(); err != nil { - db.Close() - return nil, fmt.Errorf("failed to ping TiDB database: %w", err) - } - - return db, nil -} - -func (b *TiDBDBBackend) GetInitSQL() []string { - return []string{ - // Queue registry table to track all queue tables - `CREATE TABLE IF NOT EXISTS queuefs_registry ( - queue_name VARCHAR(255) PRIMARY KEY, - table_name VARCHAR(255) NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, - } -} - -// Helper functions - -func extractDatabaseName(dsn string, configDB string) string { - if dsn != "" { - re := regexp.MustCompile(`\)/([^?]+)`) - if matches := re.FindStringSubmatch(dsn); len(matches) > 1 { - return matches[1] - } - } - return configDB -} - -func removeDatabaseFromDSN(dsn string) string { - re := regexp.MustCompile(`\)/[^?]+(\?|$)`) - return re.ReplaceAllString(dsn, ")/$1") -} - -// sanitizeTableName converts a queue name to a safe table name -// Replaces / with _ and ensures the name is safe for SQL -func sanitizeTableName(queueName string) string { - // Replace forward slashes with underscores - tableName := strings.ReplaceAll(queueName, "/", "_") - - // Replace any other potentially problematic characters - tableName = strings.ReplaceAll(tableName, "-", "_") - tableName = strings.ReplaceAll(tableName, ".", "_") - - // Prefix with queuefs_queue_ to avoid conflicts with system tables - return "queuefs_queue_" + tableName -} - -// getCreateTableSQL returns the SQL to create a queue table -func getCreateTableSQL(tableName string) string { - return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - message_id VARCHAR(64) NOT NULL, - data LONGBLOB NOT NULL, - timestamp BIGINT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted TINYINT(1) DEFAULT 0, - deleted_at TIMESTAMP NULL, - INDEX idx_deleted_id (deleted, id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, tableName) -} - -// CreateBackend creates the appropriate database backend -func CreateBackend(cfg map[string]interface{}) (DBBackend, error) { - backendType := config.GetStringConfig(cfg, "backend", "memory") - - switch backendType { - case "sqlite", "sqlite3": - return NewSQLiteDBBackend(), nil - case "tidb", "mysql": - return NewTiDBDBBackend(), nil - default: - return nil, fmt.Errorf("unsupported database backend: %s", backendType) - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/queuefs/queuefs.go b/third_party/agfs/agfs-server/pkg/plugins/queuefs/queuefs.go deleted file mode 100644 index 052a8f19d..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/queuefs/queuefs.go +++ /dev/null @@ -1,1224 +0,0 @@ -package queuefs - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "path" - "strconv" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - "github.com/google/uuid" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "queuefs" // Name of this plugin -) - -// Meta values for QueueFS plugin -const ( - MetaValueQueueControl = "control" // Queue control files (enqueue, dequeue, peek, clear) - MetaValueQueueStatus = "status" // Queue status files (size) -) - -// QueueFSPlugin provides a message queue service through a file system interface. -// Each queue is a directory containing control files: -// -// /queue_name/enqueue - write to this file to enqueue a message -// /queue_name/dequeue - read from this file to dequeue a message -// /queue_name/peek - read to peek at the next message without removing it -// The peek file's modTime reflects the latest enqueued message timestamp -// This can be used for implementing poll offset logic -// /queue_name/size - read to get queue size -// /queue_name/clear - write to this file to clear the queue -// -// Supports multiple backends: -// - memory (default): In-memory storage -// - tidb: TiDB database storage with TLS support -// - sqlite: SQLite database storage -type QueueFSPlugin struct { - backend QueueBackend - mu sync.RWMutex // Protects backend operations - metadata plugin.PluginMetadata -} - -// Queue represents a single message queue (for memory backend) -type Queue struct { - messages []QueueMessage - mu sync.Mutex - lastEnqueueTime time.Time // Tracks the timestamp of the most recently enqueued message -} - -type QueueMessage struct { - ID string `json:"id"` - Data string `json:"data"` - Timestamp time.Time `json:"timestamp"` -} - -// NewQueueFSPlugin creates a new queue plugin -func NewQueueFSPlugin() *QueueFSPlugin { - return &QueueFSPlugin{ - metadata: plugin.PluginMetadata{ - Name: PluginName, - Version: "1.0.0", - Description: "Message queue service plugin with multiple queue support and pluggable backends", - Author: "AGFS Server", - }, - } -} - -func (q *QueueFSPlugin) Name() string { - return q.metadata.Name -} - -func (q *QueueFSPlugin) Validate(cfg map[string]interface{}) error { - // Allowed configuration keys - allowedKeys := []string{ - "backend", "mount_path", - // Database-related keys - "db_path", "dsn", "user", "password", "host", "port", "database", - "enable_tls", "tls_server_name", "tls_skip_verify", - } - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate backend type - backendType := config.GetStringConfig(cfg, "backend", "memory") - validBackends := map[string]bool{ - "memory": true, - "tidb": true, - "mysql": true, - "sqlite": true, - "sqlite3": true, - } - if !validBackends[backendType] { - return fmt.Errorf("unsupported backend: %s (valid options: memory, tidb, mysql, sqlite)", backendType) - } - - // Validate database-related parameters if backend is not memory - if backendType != "memory" { - for _, key := range []string{"db_path", "dsn", "user", "password", "host", "database", "tls_server_name"} { - if err := config.ValidateStringType(cfg, key); err != nil { - return err - } - } - - for _, key := range []string{"port"} { - if err := config.ValidateIntType(cfg, key); err != nil { - return err - } - } - - for _, key := range []string{"enable_tls", "tls_skip_verify"} { - if err := config.ValidateBoolType(cfg, key); err != nil { - return err - } - } - } - - return nil -} - -func (q *QueueFSPlugin) Initialize(cfg map[string]interface{}) error { - backendType := config.GetStringConfig(cfg, "backend", "memory") - - // Create appropriate backend - var backend QueueBackend - var err error - - switch backendType { - case "memory": - backend = NewMemoryBackend() - case "sqlite", "sqlite3": - backend = NewSQLiteQueueBackend() - case "tidb", "mysql": - backend = NewTiDBBackend() - default: - return fmt.Errorf("unsupported backend: %s", backendType) - } - - // Initialize backend - if err = backend.Initialize(cfg); err != nil { - return fmt.Errorf("failed to initialize %s backend: %w", backendType, err) - } - - q.backend = backend - - log.Infof("[queuefs] Initialized with backend: %s", backendType) - return nil -} - -func (q *QueueFSPlugin) GetFileSystem() filesystem.FileSystem { - return &queueFS{plugin: q} -} - -func (q *QueueFSPlugin) GetReadme() string { - return `QueueFS Plugin - Multiple Message Queue Service - -This plugin provides multiple message queue services through a file system interface. -Each queue is a directory containing control files for queue operations. - -STRUCTURE: - /queuefs/ - README - This documentation - / - A queue directory - enqueue - Write-only file to enqueue messages - dequeue - Read-only file to dequeue messages - peek - Read-only file to peek at next message - size - Read-only file showing queue size - clear - Write-only file to clear all messages - -WORKFLOW: - 1. Create a queue: - mkdir /queuefs/my_queue - - 2. Enqueue messages: - echo "your message" > /queuefs/my_queue/enqueue - - 3. Dequeue messages: - cat /queuefs/my_queue/dequeue - - 4. Check queue size: - cat /queuefs/my_queue/size - - 5. Peek without removing: - cat /queuefs/my_queue/peek - - 6. Clear the queue: - echo "" > /queuefs/my_queue/clear - - 7. Delete the queue: - rm -rf /queuefs/my_queue - -NESTED QUEUES: - You can create queues in nested directories: - mkdir -p /queuefs/logs/errors - echo "error: timeout" > /queuefs/logs/errors/enqueue - cat /queuefs/logs/errors/dequeue - -BACKENDS: - - Memory Backend (default): - [plugins.queuefs] - enabled = true - path = "/queuefs" - # No additional config needed for memory backend - - SQLite Backend: - [plugins.queuefs] - enabled = true - path = "/queuefs" - - [plugins.queuefs.config] - backend = "sqlite" - db_path = "queue.db" - - TiDB Backend (local): - [plugins.queuefs] - enabled = true - path = "/queuefs" - - [plugins.queuefs.config] - backend = "tidb" - host = "127.0.0.1" - port = "4000" - user = "root" - password = "" - database = "queuedb" - - TiDB Cloud Backend (with TLS): - [plugins.queuefs] - enabled = true - path = "/queuefs" - - [plugins.queuefs.config] - backend = "tidb" - user = "3YdGXuXNdAEmP1f.root" - password = "your_password" - host = "gateway01.us-west-2.prod.aws.tidbcloud.com" - port = "4000" - database = "queuedb" - enable_tls = true - tls_server_name = "gateway01.us-west-2.prod.aws.tidbcloud.com" - -EXAMPLES: - # Create multiple queues - agfs:/> mkdir /queuefs/orders - agfs:/> mkdir /queuefs/notifications - agfs:/> mkdir /queuefs/logs/errors - - # Enqueue messages to different queues - agfs:/> echo "order-123" > /queuefs/orders/enqueue - agfs:/> echo "user login" > /queuefs/notifications/enqueue - agfs:/> echo "connection timeout" > /queuefs/logs/errors/enqueue - - # Check queue sizes - agfs:/> cat /queuefs/orders/size - 1 - - # Dequeue messages - agfs:/> cat /queuefs/orders/dequeue - {"id":"...","data":"order-123","timestamp":"..."} - - # List all queues - agfs:/> ls /queuefs/ - README orders notifications logs - - # Delete a queue when done - agfs:/> rm -rf /queuefs/orders - -BACKEND COMPARISON: - - memory: Fastest, no persistence, lost on restart - - sqlite: Good for single server, persistent, file-based - - tidb: Best for production, distributed, scalable, persistent -` -} - -func (q *QueueFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "backend", - Type: "string", - Required: false, - Default: "memory", - Description: "Queue backend (memory, tidb, mysql, sqlite, sqlite3)", - }, - { - Name: "db_path", - Type: "string", - Required: false, - Default: "", - Description: "Database file path (for SQLite)", - }, - { - Name: "dsn", - Type: "string", - Required: false, - Default: "", - Description: "Database connection string (DSN)", - }, - { - Name: "user", - Type: "string", - Required: false, - Default: "", - Description: "Database username", - }, - { - Name: "password", - Type: "string", - Required: false, - Default: "", - Description: "Database password", - }, - { - Name: "host", - Type: "string", - Required: false, - Default: "", - Description: "Database host", - }, - { - Name: "port", - Type: "int", - Required: false, - Default: "", - Description: "Database port", - }, - { - Name: "database", - Type: "string", - Required: false, - Default: "", - Description: "Database name", - }, - { - Name: "enable_tls", - Type: "bool", - Required: false, - Default: "false", - Description: "Enable TLS for database connection", - }, - { - Name: "tls_server_name", - Type: "string", - Required: false, - Default: "", - Description: "TLS server name for verification", - }, - { - Name: "tls_skip_verify", - Type: "bool", - Required: false, - Default: "false", - Description: "Skip TLS certificate verification", - }, - } -} - -func (q *QueueFSPlugin) Shutdown() error { - q.mu.Lock() - defer q.mu.Unlock() - - if q.backend != nil { - return q.backend.Close() - } - return nil -} - -// queueFS implements the FileSystem interface for queue operations -type queueFS struct { - plugin *QueueFSPlugin -} - -// Control file operations supported within each queue directory -var queueOperations = map[string]bool{ - "enqueue": true, - "dequeue": true, - "peek": true, - "size": true, - "clear": true, - "ack": true, // write message_id to confirm processing complete (at-least-once delivery) -} - -// parseQueuePath parses a path like "/queue_name/operation" or "/dir/queue_name/operation" -// Returns (queueName, operation, isDir, error) -func parseQueuePath(p string) (queueName string, operation string, isDir bool, err error) { - // Clean the path - p = path.Clean(p) - - if p == "/" || p == "." { - return "", "", true, nil - } - - // Remove leading slash - p = strings.TrimPrefix(p, "/") - - // Split path into components - parts := strings.Split(p, "/") - - if len(parts) == 0 { - return "", "", true, nil - } - - // Check if the last component is a queue operation - lastPart := parts[len(parts)-1] - if queueOperations[lastPart] { - // This is a queue operation file - if len(parts) == 1 { - return "", "", false, fmt.Errorf("invalid path: operation without queue name") - } - queueName = strings.Join(parts[:len(parts)-1], "/") - operation = lastPart - return queueName, operation, false, nil - } - - // This is a queue directory (or parent directory) - queueName = strings.Join(parts, "/") - return queueName, "", true, nil -} - -// isValidQueueOperation checks if an operation name is valid -func isValidQueueOperation(op string) bool { - return queueOperations[op] -} - -func (qfs *queueFS) Create(path string) error { - _, operation, isDir, err := parseQueuePath(path) - if err != nil { - return err - } - - if isDir { - return fmt.Errorf("cannot create files: %s is a directory", path) - } - - if operation != "" && isValidQueueOperation(operation) { - // Control files are virtual, no need to create - return nil - } - - return fmt.Errorf("cannot create files in queuefs: %s", path) -} - -func (qfs *queueFS) Mkdir(path string, perm uint32) error { - queueName, _, isDir, err := parseQueuePath(path) - if err != nil { - return err - } - - if !isDir { - return fmt.Errorf("cannot create directory: %s is not a valid directory path", path) - } - - if queueName == "" { - return fmt.Errorf("invalid queue name") - } - - // Create queue in backend - qfs.plugin.mu.Lock() - defer qfs.plugin.mu.Unlock() - - return qfs.plugin.backend.CreateQueue(queueName) -} - -func (qfs *queueFS) Remove(path string) error { - _, operation, isDir, err := parseQueuePath(path) - if err != nil { - return err - } - - if isDir { - return fmt.Errorf("cannot remove directory with Remove: use RemoveAll instead") - } - - if operation != "" { - return fmt.Errorf("cannot remove control files: %s", path) - } - - return fmt.Errorf("cannot remove: %s", path) -} - -func (qfs *queueFS) RemoveAll(path string) error { - queueName, _, isDir, err := parseQueuePath(path) - if err != nil { - return err - } - - if !isDir { - return fmt.Errorf("cannot remove: %s is not a directory", path) - } - - qfs.plugin.mu.Lock() - defer qfs.plugin.mu.Unlock() - - return qfs.plugin.backend.RemoveQueue(queueName) -} - -func (qfs *queueFS) Read(path string, offset int64, size int64) ([]byte, error) { - // Special case: README at root - if path == "/README" { - data := []byte(qfs.plugin.GetReadme()) - return plugin.ApplyRangeRead(data, offset, size) - } - - queueName, operation, isDir, err := parseQueuePath(path) - if err != nil { - return nil, err - } - - if isDir { - return nil, fmt.Errorf("is a directory: %s", path) - } - - if operation == "" { - return nil, filesystem.NewNotFoundError("read", path) - } - - var data []byte - - switch operation { - case "dequeue": - data, err = qfs.dequeue(queueName) - case "peek": - data, err = qfs.peek(queueName) - case "size": - data, err = qfs.size(queueName) - case "enqueue", "clear", "ack": - // Write-only files - return []byte(""), fmt.Errorf("permission denied: %s is write-only", path) - default: - return nil, filesystem.NewNotFoundError("read", path) - } - - if err != nil { - return nil, err - } - - return plugin.ApplyRangeRead(data, offset, size) -} - -func (qfs *queueFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - queueName, operation, isDir, err := parseQueuePath(path) - if err != nil { - return 0, err - } - - if isDir { - return 0, fmt.Errorf("is a directory: %s", path) - } - - if operation == "" { - return 0, fmt.Errorf("cannot write to: %s", path) - } - - // QueueFS is append-only for enqueue, offset is ignored - switch operation { - case "enqueue": - // TODO: ignore the enqueue content to fit the FS interface - _, err := qfs.enqueue(queueName, data) - if err != nil { - return 0, err - } - // Note: msgID is no longer returned via Write return value - // Clients should use other mechanisms (e.g., response headers) if needed - return int64(len(data)), nil - case "clear": - if err := qfs.clear(queueName); err != nil { - return 0, err - } - return 0, nil - case "ack": - msgID := strings.TrimSpace(string(data)) - if err := qfs.ackMessage(queueName, msgID); err != nil { - return 0, err - } - return int64(len(data)), nil - default: - return 0, fmt.Errorf("cannot write to: %s", path) - } -} - -func (qfs *queueFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - queueName, _, isDir, err := parseQueuePath(path) - if err != nil { - return nil, err - } - - if !isDir { - return nil, fmt.Errorf("not a directory: %s", path) - } - - now := time.Now() - - // Root directory: list all queues + README - if path == "/" || queueName == "" { - qfs.plugin.mu.RLock() - defer qfs.plugin.mu.RUnlock() - - readme := qfs.plugin.GetReadme() - files := []filesystem.FileInfo{ - { - Name: "README", - Size: int64(len(readme)), - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "doc"}, - }, - } - - // Get all queues from backend - queues, err := qfs.plugin.backend.ListQueues("") - if err != nil { - return nil, err - } - - // Extract top-level directories - topLevelDirs := make(map[string]bool) - for _, qName := range queues { - parts := strings.Split(qName, "/") - if len(parts) > 0 { - topLevelDirs[parts[0]] = true - } - } - - for dirName := range topLevelDirs { - files = append(files, filesystem.FileInfo{ - Name: dirName, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "queue"}, - }) - } - - return files, nil - } - - // Check if this is an actual queue or intermediate directory - qfs.plugin.mu.RLock() - defer qfs.plugin.mu.RUnlock() - - // Check if queue has messages - size, err := qfs.plugin.backend.Size(queueName) - if err != nil { - return nil, err - } - - if size > 0 { - // This is an actual queue with messages - return control files - return qfs.getQueueControlFiles(queueName, now) - } - - // Check for nested queues - queues, err := qfs.plugin.backend.ListQueues(queueName) - if err != nil { - return nil, err - } - - subdirs := make(map[string]bool) - hasNested := false - - for _, qName := range queues { - if qName == queueName { - continue - } - if strings.HasPrefix(qName, queueName+"/") { - hasNested = true - remainder := strings.TrimPrefix(qName, queueName+"/") - parts := strings.Split(remainder, "/") - if len(parts) > 0 { - subdirs[parts[0]] = true - } - } - } - - if !hasNested { - // No messages and no nested queues - treat as empty queue directory - return qfs.getQueueControlFiles(queueName, now) - } - - // Return subdirectories - var files []filesystem.FileInfo - for subdir := range subdirs { - files = append(files, filesystem.FileInfo{ - Name: subdir, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "queue"}, - }) - } - - return files, nil -} - -func (qfs *queueFS) getQueueControlFiles(queueName string, now time.Time) ([]filesystem.FileInfo, error) { - // Get queue size - queueSize, err := qfs.plugin.backend.Size(queueName) - if err != nil { - queueSize = 0 - } - - // Get last enqueue time for peek ModTime - lastEnqueueTime, err := qfs.plugin.backend.GetLastEnqueueTime(queueName) - if err != nil || lastEnqueueTime.IsZero() { - lastEnqueueTime = now - } - - files := []filesystem.FileInfo{ - { - Name: "enqueue", - Size: 0, - Mode: 0222, // write-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: MetaValueQueueControl}, - }, - { - Name: "dequeue", - Size: 0, - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: MetaValueQueueControl}, - }, - { - Name: "peek", - Size: 0, - Mode: 0444, // read-only - ModTime: lastEnqueueTime, // Use last enqueue time for poll offset tracking - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: MetaValueQueueControl}, - }, - { - Name: "size", - Size: int64(len(strconv.Itoa(queueSize))), - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: MetaValueQueueStatus}, - }, - { - Name: "clear", - Size: 0, - Mode: 0222, // write-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: MetaValueQueueControl}, - }, - } - - return files, nil -} - -func (qfs *queueFS) Stat(p string) (*filesystem.FileInfo, error) { - if p == "/" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Content: map[string]string{ - "backend": qfs.plugin.backend.GetType(), - }, - }, - }, nil - } - - // Special case: README at root - if p == "/README" { - readme := qfs.plugin.GetReadme() - return &filesystem.FileInfo{ - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "doc"}, - }, nil - } - - queueName, operation, isDir, err := parseQueuePath(p) - if err != nil { - return nil, err - } - - now := time.Now() - - // Directory stat - if isDir { - name := path.Base(p) - if name == "." || name == "/" { - name = "/" - } - - // Check if queue exists - qfs.plugin.mu.RLock() - exists, err := qfs.plugin.backend.QueueExists(queueName) - if err != nil { - qfs.plugin.mu.RUnlock() - return nil, fmt.Errorf("failed to check queue existence: %w", err) - } - - // If queue doesn't exist, check if it's a parent directory of existing queues - if !exists { - queues, err := qfs.plugin.backend.ListQueues(queueName) - if err != nil { - qfs.plugin.mu.RUnlock() - return nil, fmt.Errorf("failed to list queues: %w", err) - } - // Check if any queue starts with this path as a prefix - hasChildren := false - for _, q := range queues { - if strings.HasPrefix(q, queueName+"/") { - hasChildren = true - break - } - } - if !hasChildren { - qfs.plugin.mu.RUnlock() - return nil, filesystem.NewNotFoundError("stat", p) - } - } - qfs.plugin.mu.RUnlock() - - return &filesystem.FileInfo{ - Name: name, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "queue"}, - }, nil - } - - // Control file stat - if operation == "" { - return nil, filesystem.NewNotFoundError("stat", p) - } - - mode := uint32(0644) - if operation == "enqueue" || operation == "clear" || operation == "ack" { - mode = 0222 - } else { - mode = 0444 - } - - fileType := MetaValueQueueControl - size := int64(0) - modTime := now - - if operation == "size" { - fileType = MetaValueQueueStatus - queueSize, _ := qfs.plugin.backend.Size(queueName) - size = int64(len(strconv.Itoa(queueSize))) - } else if operation == "peek" { - // Use last enqueue time for peek's ModTime - lastEnqueueTime, err := qfs.plugin.backend.GetLastEnqueueTime(queueName) - if err == nil && !lastEnqueueTime.IsZero() { - modTime = lastEnqueueTime - } - } - - return &filesystem.FileInfo{ - Name: operation, - Size: size, - Mode: mode, - ModTime: modTime, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: fileType}, - }, nil -} - -func (qfs *queueFS) Rename(oldPath, newPath string) error { - return fmt.Errorf("cannot rename files in queuefs service") -} - -func (qfs *queueFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("cannot change permissions in queuefs service") -} - -func (qfs *queueFS) Open(path string) (io.ReadCloser, error) { - data, err := qfs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(bytes.NewReader(data)), nil -} - -func (qfs *queueFS) OpenWrite(path string) (io.WriteCloser, error) { - return &queueWriter{qfs: qfs, path: path, buf: &bytes.Buffer{}}, nil -} - -type queueWriter struct { - qfs *queueFS - path string - buf *bytes.Buffer -} - -func (qw *queueWriter) Write(p []byte) (n int, err error) { - return qw.buf.Write(p) -} - -func (qw *queueWriter) Close() error { - _, err := qw.qfs.Write(qw.path, qw.buf.Bytes(), -1, filesystem.WriteFlagAppend) - return err -} - -// Queue operations - -func (qfs *queueFS) enqueue(queueName string, data []byte) ([]byte, error) { - qfs.plugin.mu.Lock() - defer qfs.plugin.mu.Unlock() - - now := time.Now() - // Use UUIDv7 for globally unique and time-ordered message ID in distributed environments (e.g., TiDB backend) - // UUIDv7 is time-sortable and ensures uniqueness across distributed systems - msgUUID, err := uuid.NewV7() - if err != nil { - return nil, fmt.Errorf("failed to generate UUIDv7: %w", err) - } - msgID := msgUUID.String() - msg := QueueMessage{ - ID: msgID, - Data: string(data), - Timestamp: now, - } - - err = qfs.plugin.backend.Enqueue(queueName, msg) - if err != nil { - return nil, err - } - - return []byte(msg.ID), nil -} - -func (qfs *queueFS) dequeue(queueName string) ([]byte, error) { - qfs.plugin.mu.Lock() - defer qfs.plugin.mu.Unlock() - - msg, found, err := qfs.plugin.backend.Dequeue(queueName) - if err != nil { - return nil, err - } - - if !found { - // Return empty JSON object instead of error for empty queue - return []byte("{}"), nil - } - - return json.Marshal(msg) -} - -func (qfs *queueFS) peek(queueName string) ([]byte, error) { - qfs.plugin.mu.RLock() - defer qfs.plugin.mu.RUnlock() - - msg, found, err := qfs.plugin.backend.Peek(queueName) - if err != nil { - return nil, err - } - - if !found { - // Return empty JSON object instead of error for empty queue - return []byte("{}"), nil - } - - return json.Marshal(msg) -} - -func (qfs *queueFS) size(queueName string) ([]byte, error) { - qfs.plugin.mu.RLock() - defer qfs.plugin.mu.RUnlock() - - count, err := qfs.plugin.backend.Size(queueName) - if err != nil { - return nil, err - } - - return []byte(strconv.Itoa(count)), nil -} - -func (qfs *queueFS) clear(queueName string) error { - qfs.plugin.mu.Lock() - defer qfs.plugin.mu.Unlock() - - return qfs.plugin.backend.Clear(queueName) -} - -func (qfs *queueFS) ackMessage(queueName string, msgID string) error { - qfs.plugin.mu.Lock() - defer qfs.plugin.mu.Unlock() - - return qfs.plugin.backend.Ack(queueName, msgID) -} - -// Ensure QueueFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*QueueFSPlugin)(nil) -var _ filesystem.FileSystem = (*queueFS)(nil) -var _ filesystem.HandleFS = (*queueFS)(nil) - -// ============================================================================ -// HandleFS Implementation for QueueFS -// ============================================================================ - -// queueFileHandle represents an open handle to a queue control file -type queueFileHandle struct { - id int64 - qfs *queueFS - path string - queueName string - operation string // "enqueue", "dequeue", "peek", "size", "clear" - flags filesystem.OpenFlag - - // For dequeue/peek: cached message data (read once, return from cache) - readBuffer []byte - readDone bool - - mu sync.Mutex -} - -// handleManager manages open handles for queueFS -type handleManager struct { - handles map[int64]*queueFileHandle - nextID int64 - mu sync.Mutex -} - -// Global handle manager for queueFS (per plugin instance would be better, but keeping it simple) -var queueHandleManager = &handleManager{ - handles: make(map[int64]*queueFileHandle), - nextID: 1, -} - -// OpenHandle opens a file and returns a handle for stateful operations -func (qfs *queueFS) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - queueName, operation, isDir, err := parseQueuePath(path) - if err != nil { - return nil, err - } - - if isDir { - return nil, fmt.Errorf("cannot open directory as file: %s", path) - } - - if operation == "" { - return nil, fmt.Errorf("cannot open queue directory: %s", path) - } - - // Validate operation - if !queueOperations[operation] { - return nil, fmt.Errorf("unknown operation: %s", operation) - } - - queueHandleManager.mu.Lock() - defer queueHandleManager.mu.Unlock() - - id := queueHandleManager.nextID - queueHandleManager.nextID++ - - handle := &queueFileHandle{ - id: id, - qfs: qfs, - path: path, - queueName: queueName, - operation: operation, - flags: flags, - } - - queueHandleManager.handles[id] = handle - return handle, nil -} - -// GetHandle retrieves an existing handle by its ID -func (qfs *queueFS) GetHandle(id int64) (filesystem.FileHandle, error) { - queueHandleManager.mu.Lock() - defer queueHandleManager.mu.Unlock() - - handle, ok := queueHandleManager.handles[id] - if !ok { - return nil, filesystem.ErrNotFound - } - return handle, nil -} - -// CloseHandle closes a handle by its ID -func (qfs *queueFS) CloseHandle(id int64) error { - queueHandleManager.mu.Lock() - defer queueHandleManager.mu.Unlock() - - handle, ok := queueHandleManager.handles[id] - if !ok { - return filesystem.ErrNotFound - } - - delete(queueHandleManager.handles, id) - _ = handle // Clear reference - return nil -} - -// ============================================================================ -// FileHandle Implementation -// ============================================================================ - -func (h *queueFileHandle) ID() int64 { - return h.id -} - -func (h *queueFileHandle) Path() string { - return h.path -} - -func (h *queueFileHandle) Flags() filesystem.OpenFlag { - return h.flags -} - -func (h *queueFileHandle) Read(buf []byte) (int, error) { - return h.ReadAt(buf, 0) -} - -func (h *queueFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - // For dequeue/peek: fetch data once and cache it - if !h.readDone { - var data []byte - var err error - - switch h.operation { - case "dequeue": - data, err = h.qfs.dequeue(h.queueName) - case "peek": - data, err = h.qfs.peek(h.queueName) - case "size": - data, err = h.qfs.size(h.queueName) - case "enqueue", "clear": - // These are write-only operations - return 0, io.EOF - default: - return 0, fmt.Errorf("unsupported read operation: %s", h.operation) - } - - if err != nil { - return 0, err - } - - h.readBuffer = data - h.readDone = true - } - - // Return from cache - if offset >= int64(len(h.readBuffer)) { - return 0, io.EOF - } - - n := copy(buf, h.readBuffer[offset:]) - return n, nil -} - -func (h *queueFileHandle) Write(data []byte) (int, error) { - return h.WriteAt(data, 0) -} - -func (h *queueFileHandle) WriteAt(data []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - switch h.operation { - case "enqueue": - _, err := h.qfs.enqueue(h.queueName, data) - if err != nil { - return 0, err - } - return len(data), nil - case "clear": - err := h.qfs.clear(h.queueName) - if err != nil { - return 0, err - } - return len(data), nil - case "dequeue", "peek", "size": - return 0, fmt.Errorf("cannot write to %s", h.operation) - default: - return 0, fmt.Errorf("unsupported write operation: %s", h.operation) - } -} - -func (h *queueFileHandle) Seek(offset int64, whence int) (int64, error) { - // Queue files don't support seeking in the traditional sense - // Just return 0 for compatibility - return 0, nil -} - -func (h *queueFileHandle) Sync() error { - // Nothing to sync for queue operations - return nil -} - -func (h *queueFileHandle) Close() error { - queueHandleManager.mu.Lock() - defer queueHandleManager.mu.Unlock() - - delete(queueHandleManager.handles, h.id) - return nil -} - -func (h *queueFileHandle) Stat() (*filesystem.FileInfo, error) { - return h.qfs.Stat(h.path) -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/queuefs/sqlite_backend.go b/third_party/agfs/agfs-server/pkg/plugins/queuefs/sqlite_backend.go deleted file mode 100644 index 2a0c4dbed..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/queuefs/sqlite_backend.go +++ /dev/null @@ -1,321 +0,0 @@ -package queuefs - -import ( - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" - - log "github.com/sirupsen/logrus" -) - -// SQLiteQueueBackend implements QueueBackend using SQLite with a single-table schema. -// -// Schema: -// - queue_metadata: tracks all queues (including empty ones created via mkdir) -// - queue_messages: stores all messages, filtered by queue_name column -// - status: 'pending' (waiting to be processed) | 'processing' (dequeued, awaiting ack) -// - processing_started_at: Unix timestamp when dequeued; NULL while pending -// -// Delivery semantics: at-least-once -// - Dequeue marks message as 'processing' (does NOT delete) -// - Ack deletes the message after successful processing -// - On startup, RecoverStale resets all 'processing' messages back to 'pending' -// so that messages from a previous crashed run are automatically retried -type SQLiteQueueBackend struct { - db *sql.DB -} - -func NewSQLiteQueueBackend() *SQLiteQueueBackend { - return &SQLiteQueueBackend{} -} - -func (b *SQLiteQueueBackend) Initialize(config map[string]interface{}) error { - dbBackend := NewSQLiteDBBackend() - - db, err := dbBackend.Open(config) - if err != nil { - return fmt.Errorf("failed to open SQLite database: %w", err) - } - b.db = db - - for _, sqlStmt := range dbBackend.GetInitSQL() { - if _, err := db.Exec(sqlStmt); err != nil { - db.Close() - return fmt.Errorf("failed to initialize schema: %w", err) - } - } - - // Migrate existing databases: add new columns if they don't exist yet. - b.runMigrations() - - // Reset any messages left in 'processing' state by a previous crashed process. - // staleSec=0 resets ALL processing messages — safe at startup because no workers - // are running yet. - if n, err := b.RecoverStale(0); err != nil { - log.Warnf("[queuefs] Failed to recover stale messages on startup: %v", err) - } else if n > 0 { - log.Infof("[queuefs] Recovered %d in-flight message(s) from previous run", n) - } - - log.Info("[queuefs] SQLite backend initialized") - return nil -} - -// runMigrations applies schema changes needed to upgrade an existing database. -// Each ALTER TABLE is executed and "duplicate column name" errors are silently ignored. -func (b *SQLiteQueueBackend) runMigrations() { - migrations := []string{ - `ALTER TABLE queue_messages ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'`, - `ALTER TABLE queue_messages ADD COLUMN processing_started_at INTEGER`, - `CREATE INDEX IF NOT EXISTS idx_queue_status ON queue_messages(queue_name, status, id)`, - `CREATE INDEX IF NOT EXISTS idx_queue_message_id ON queue_messages(queue_name, message_id)`, - } - for _, stmt := range migrations { - if _, err := b.db.Exec(stmt); err != nil { - // "duplicate column name" means the column already exists — that's fine. - if !strings.Contains(err.Error(), "duplicate column name") && - !strings.Contains(err.Error(), "already exists") { - log.Warnf("[queuefs] Migration warning: %v", err) - } - } - } -} - -func (b *SQLiteQueueBackend) Close() error { - if b.db != nil { - return b.db.Close() - } - return nil -} - -func (b *SQLiteQueueBackend) GetType() string { - return "sqlite" -} - -func (b *SQLiteQueueBackend) Enqueue(queueName string, msg QueueMessage) error { - msgData, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - _, err = b.db.Exec( - "INSERT INTO queue_messages (queue_name, message_id, data, timestamp, status) VALUES (?, ?, ?, ?, 'pending')", - queueName, msg.ID, string(msgData), msg.Timestamp.Unix(), - ) - if err != nil { - return fmt.Errorf("failed to enqueue message: %w", err) - } - return nil -} - -// Dequeue marks the first pending message as 'processing' and returns it. -// The message remains in the database until Ack is called. -// If the process crashes before Ack, RecoverStale on the next startup will -// reset the message back to 'pending' so it is retried. -func (b *SQLiteQueueBackend) Dequeue(queueName string) (QueueMessage, bool, error) { - tx, err := b.db.Begin() - if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to start transaction: %w", err) - } - defer tx.Rollback() - - var id int64 - var data string - err = tx.QueryRow( - "SELECT id, data FROM queue_messages WHERE queue_name = ? AND status = 'pending' ORDER BY id LIMIT 1", - queueName, - ).Scan(&id, &data) - - if err == sql.ErrNoRows { - return QueueMessage{}, false, nil - } else if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to query message: %w", err) - } - - // Mark as processing instead of deleting. - _, err = tx.Exec( - "UPDATE queue_messages SET status = 'processing', processing_started_at = ? WHERE id = ?", - time.Now().Unix(), id, - ) - if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to mark message as processing: %w", err) - } - - if err := tx.Commit(); err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to commit transaction: %w", err) - } - - var msg QueueMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to unmarshal message: %w", err) - } - - return msg, true, nil -} - -// Ack deletes a message that has been successfully processed. -// Should be called after the consumer has finished processing the message. -func (b *SQLiteQueueBackend) Ack(queueName string, messageID string) error { - result, err := b.db.Exec( - "DELETE FROM queue_messages WHERE queue_name = ? AND message_id = ? AND status = 'processing'", - queueName, messageID, - ) - if err != nil { - return fmt.Errorf("failed to ack message: %w", err) - } - rows, _ := result.RowsAffected() - if rows == 0 { - log.Warnf("[queuefs] Ack found no matching processing message: queue=%s msg=%s", queueName, messageID) - } - return nil -} - -// RecoverStale resets messages stuck in 'processing' state back to 'pending'. -// staleSec is the minimum age (in seconds) of a processing message before it -// is considered stale. Pass 0 to reset ALL processing messages immediately -// (appropriate at startup before any workers have started). -// Returns the number of messages recovered. -func (b *SQLiteQueueBackend) RecoverStale(staleSec int64) (int, error) { - cutoff := time.Now().Unix() - staleSec - result, err := b.db.Exec( - "UPDATE queue_messages SET status = 'pending', processing_started_at = NULL WHERE status = 'processing' AND processing_started_at <= ?", - cutoff, - ) - if err != nil { - return 0, fmt.Errorf("failed to recover stale messages: %w", err) - } - n, _ := result.RowsAffected() - return int(n), nil -} - -func (b *SQLiteQueueBackend) Peek(queueName string) (QueueMessage, bool, error) { - var data string - err := b.db.QueryRow( - "SELECT data FROM queue_messages WHERE queue_name = ? AND status = 'pending' ORDER BY id LIMIT 1", - queueName, - ).Scan(&data) - - if err == sql.ErrNoRows { - return QueueMessage{}, false, nil - } else if err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to peek message: %w", err) - } - - var msg QueueMessage - if err := json.Unmarshal([]byte(data), &msg); err != nil { - return QueueMessage{}, false, fmt.Errorf("failed to unmarshal message: %w", err) - } - - return msg, true, nil -} - -// Size returns the number of pending (not yet dequeued) messages. -func (b *SQLiteQueueBackend) Size(queueName string) (int, error) { - var count int - err := b.db.QueryRow( - "SELECT COUNT(*) FROM queue_messages WHERE queue_name = ? AND status = 'pending'", - queueName, - ).Scan(&count) - if err != nil { - return 0, fmt.Errorf("failed to get queue size: %w", err) - } - return count, nil -} - -func (b *SQLiteQueueBackend) Clear(queueName string) error { - _, err := b.db.Exec("DELETE FROM queue_messages WHERE queue_name = ?", queueName) - if err != nil { - return fmt.Errorf("failed to clear queue: %w", err) - } - return nil -} - -func (b *SQLiteQueueBackend) ListQueues(prefix string) ([]string, error) { - var query string - var args []interface{} - - if prefix == "" { - query = "SELECT queue_name FROM queue_metadata" - } else { - query = "SELECT queue_name FROM queue_metadata WHERE queue_name = ? OR queue_name LIKE ?" - args = []interface{}{prefix, prefix + "/%"} - } - - rows, err := b.db.Query(query, args...) - if err != nil { - return nil, fmt.Errorf("failed to list queues: %w", err) - } - defer rows.Close() - - var queues []string - for rows.Next() { - var qName string - if err := rows.Scan(&qName); err != nil { - return nil, fmt.Errorf("failed to scan queue name: %w", err) - } - queues = append(queues, qName) - } - return queues, nil -} - -func (b *SQLiteQueueBackend) GetLastEnqueueTime(queueName string) (time.Time, error) { - var timestamp sql.NullInt64 - err := b.db.QueryRow( - "SELECT MAX(timestamp) FROM queue_messages WHERE queue_name = ? AND status = 'pending'", - queueName, - ).Scan(×tamp) - - if err != nil || !timestamp.Valid { - return time.Time{}, nil - } - return time.Unix(timestamp.Int64, 0), nil -} - -func (b *SQLiteQueueBackend) RemoveQueue(queueName string) error { - if queueName == "" { - if _, err := b.db.Exec("DELETE FROM queue_messages"); err != nil { - return err - } - _, err := b.db.Exec("DELETE FROM queue_metadata") - return err - } - - if _, err := b.db.Exec( - "DELETE FROM queue_messages WHERE queue_name = ? OR queue_name LIKE ?", - queueName, queueName+"/%", - ); err != nil { - return fmt.Errorf("failed to remove queue messages: %w", err) - } - - _, err := b.db.Exec( - "DELETE FROM queue_metadata WHERE queue_name = ? OR queue_name LIKE ?", - queueName, queueName+"/%", - ) - return err -} - -func (b *SQLiteQueueBackend) CreateQueue(queueName string) error { - _, err := b.db.Exec( - "INSERT OR IGNORE INTO queue_metadata (queue_name) VALUES (?)", - queueName, - ) - if err != nil { - return fmt.Errorf("failed to create queue: %w", err) - } - log.Infof("[queuefs] Created queue '%s' (SQLite)", queueName) - return nil -} - -func (b *SQLiteQueueBackend) QueueExists(queueName string) (bool, error) { - var count int - err := b.db.QueryRow( - "SELECT COUNT(*) FROM queue_metadata WHERE queue_name = ?", - queueName, - ).Scan(&count) - if err != nil { - return false, fmt.Errorf("failed to check queue existence: %w", err) - } - return count > 0, nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/s3fs/README.md b/third_party/agfs/agfs-server/pkg/plugins/s3fs/README.md deleted file mode 100644 index 06018ba55..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/s3fs/README.md +++ /dev/null @@ -1,155 +0,0 @@ -S3FS Plugin - AWS S3-backed File System - -This plugin provides a file system backed by AWS S3 object storage. - -FEATURES: - - Store files and directories in AWS S3 - - Support for S3-compatible services (MinIO, LocalStack, etc.) - - Full POSIX-like file system operations - - Automatic directory handling - - Optional key prefix for namespace isolation - -DYNAMIC MOUNTING WITH AGFS SHELL: - - Interactive shell - AWS S3: - agfs:/> mount s3fs /s3 bucket=my-bucket region=us-east-1 access_key_id=AKIAIOSFODNN7EXAMPLE secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - agfs:/> mount s3fs /backup bucket=backup-bucket region=us-west-2 access_key_id=YOUR_KEY secret_access_key=YOUR_SECRET prefix=myapp/ - - Interactive shell - S3-compatible (MinIO): - agfs:/> mount s3fs /minio bucket=my-bucket region=us-east-1 access_key_id=minioadmin secret_access_key=minioadmin endpoint=http://localhost:9000 disable_ssl=true - - Direct command - AWS S3: - uv run agfs mount s3fs /s3 bucket=my-bucket region=us-east-1 access_key_id=AKIAIOSFODNN7EXAMPLE secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - - Direct command - MinIO: - uv run agfs mount s3fs /minio bucket=test region=us-east-1 access_key_id=minioadmin secret_access_key=minioadmin endpoint=http://localhost:9000 disable_ssl=true - -CONFIGURATION PARAMETERS: - - Required: - - bucket: S3 bucket name - - region: AWS region (e.g., "us-east-1", "eu-west-1") - - access_key_id: AWS/S3 access key ID - - secret_access_key: AWS/S3 secret access key - - Optional: - - prefix: Key prefix for namespace isolation (e.g., "myapp/") - - endpoint: Custom S3 endpoint for S3-compatible services (e.g., MinIO) - - disable_ssl: Set to true to disable SSL for local services (default: false) - - Examples: - # Multiple buckets with different configurations - agfs:/> mount s3fs /s3-prod bucket=prod-bucket region=us-east-1 access_key_id=KEY1 secret_access_key=SECRET1 - agfs:/> mount s3fs /s3-dev bucket=dev-bucket region=us-west-2 access_key_id=KEY2 secret_access_key=SECRET2 prefix=dev/ - -STATIC CONFIGURATION (config.yaml): - - Alternative to dynamic mounting - configure in server config file: - - AWS S3: - [plugins.s3fs] - enabled = true - path = "/s3fs" - - [plugins.s3fs.config] - region = "us-east-1" - bucket = "my-bucket" - access_key_id = "AKIAIOSFODNN7EXAMPLE" - secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" - prefix = "agfs/" # Optional: all keys will be prefixed with this - - S3-Compatible Service (MinIO, LocalStack): - [plugins.s3fs] - enabled = true - path = "/s3fs" - - [plugins.s3fs.config] - region = "us-east-1" - bucket = "my-bucket" - access_key_id = "minioadmin" - secret_access_key = "minioadmin" - endpoint = "http://localhost:9000" - disable_ssl = true - - Multiple S3 Buckets: - [plugins.s3fs_prod] - enabled = true - path = "/s3/prod" - - [plugins.s3fs_prod.config] - region = "us-east-1" - bucket = "production-bucket" - access_key_id = "..." - secret_access_key = "..." - - [plugins.s3fs_dev] - enabled = true - path = "/s3/dev" - - [plugins.s3fs_dev.config] - region = "us-west-2" - bucket = "development-bucket" - access_key_id = "..." - secret_access_key = "..." - -USAGE: - - Create a directory: - agfs mkdir /s3fs/data - - Create a file: - agfs write /s3fs/data/file.txt "Hello, S3!" - - Read a file: - agfs cat /s3fs/data/file.txt - - List directory: - agfs ls /s3fs/data - - Remove file: - agfs rm /s3fs/data/file.txt - - Remove directory (must be empty): - agfs rm /s3fs/data - - Remove directory recursively: - agfs rm -r /s3fs/data - -EXAMPLES: - - # Basic file operations - agfs:/> mkdir /s3fs/documents - agfs:/> echo "Important data" > /s3fs/documents/report.txt - agfs:/> cat /s3fs/documents/report.txt - Important data - - # List contents - agfs:/> ls /s3fs/documents - report.txt - - # Move/rename - agfs:/> mv /s3fs/documents/report.txt /s3fs/documents/report-2024.txt - -NOTES: - - S3 doesn't have real directories; they are simulated with "/" in object keys - - Large files may take time to upload/download - - Permissions (chmod) are not supported by S3 - - Atomic operations are limited by S3's eventual consistency model - -USE CASES: - - Cloud-native file storage - - Backup and archival - - Sharing files across distributed systems - - Cost-effective long-term storage - - Integration with AWS services - -ADVANTAGES: - - Unlimited storage capacity - - High durability (99.999999999%) - - Geographic redundancy - - Pay-per-use pricing - - Versioning and lifecycle policies (via S3 bucket settings) - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/s3fs/cache.go b/third_party/agfs/agfs-server/pkg/plugins/s3fs/cache.go deleted file mode 100644 index 1907c8ec5..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/s3fs/cache.go +++ /dev/null @@ -1,352 +0,0 @@ -package s3fs - -import ( - "container/list" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -// DirCacheEntry represents a cached directory listing -type DirCacheEntry struct { - Files []filesystem.FileInfo - ModTime time.Time -} - -// StatCacheEntry represents a cached stat result -type StatCacheEntry struct { - Info *filesystem.FileInfo - ModTime time.Time -} - -// ListDirCache implements an LRU cache for directory listings -type ListDirCache struct { - mu sync.RWMutex - cache map[string]*list.Element - lruList *list.List - maxSize int - ttl time.Duration - enabled bool - hitCount uint64 - missCount uint64 -} - -// dirCacheItem is the value stored in the LRU list -type dirCacheItem struct { - path string - entry *DirCacheEntry -} - -// NewListDirCache creates a new directory listing cache -func NewListDirCache(maxSize int, ttl time.Duration, enabled bool) *ListDirCache { - if maxSize <= 0 { - maxSize = 1000 - } - if ttl <= 0 { - ttl = 30 * time.Second - } - - return &ListDirCache{ - cache: make(map[string]*list.Element), - lruList: list.New(), - maxSize: maxSize, - ttl: ttl, - enabled: enabled, - } -} - -// Get retrieves a cached directory listing -func (c *ListDirCache) Get(path string) ([]filesystem.FileInfo, bool) { - if !c.enabled { - return nil, false - } - - c.mu.Lock() - defer c.mu.Unlock() - - elem, ok := c.cache[path] - if !ok { - c.missCount++ - return nil, false - } - - item := elem.Value.(*dirCacheItem) - - // Check if entry is expired - if time.Since(item.entry.ModTime) > c.ttl { - c.lruList.Remove(elem) - delete(c.cache, path) - c.missCount++ - return nil, false - } - - // Move to front (most recently used) - c.lruList.MoveToFront(elem) - c.hitCount++ - - // Return a copy to prevent external modification - files := make([]filesystem.FileInfo, len(item.entry.Files)) - copy(files, item.entry.Files) - return files, true -} - -// Put adds a directory listing to the cache -func (c *ListDirCache) Put(path string, files []filesystem.FileInfo) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - // Check if entry already exists - if elem, ok := c.cache[path]; ok { - item := elem.Value.(*dirCacheItem) - item.entry.Files = files - item.entry.ModTime = time.Now() - c.lruList.MoveToFront(elem) - return - } - - // Create new entry - entry := &DirCacheEntry{ - Files: files, - ModTime: time.Now(), - } - - item := &dirCacheItem{ - path: path, - entry: entry, - } - - elem := c.lruList.PushFront(item) - c.cache[path] = elem - - // Evict oldest entry if cache is full - if c.lruList.Len() > c.maxSize { - oldest := c.lruList.Back() - if oldest != nil { - c.lruList.Remove(oldest) - oldestItem := oldest.Value.(*dirCacheItem) - delete(c.cache, oldestItem.path) - } - } -} - -// Invalidate removes a specific path from the cache -func (c *ListDirCache) Invalidate(path string) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - if elem, ok := c.cache[path]; ok { - c.lruList.Remove(elem) - delete(c.cache, path) - } -} - -// InvalidatePrefix removes all paths with the given prefix from cache -func (c *ListDirCache) InvalidatePrefix(prefix string) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - toDelete := make([]string, 0) - for path := range c.cache { - if path == prefix || (len(path) > len(prefix) && path[:len(prefix)] == prefix && path[len(prefix)] == '/') { - toDelete = append(toDelete, path) - } - } - - for _, path := range toDelete { - if elem, ok := c.cache[path]; ok { - c.lruList.Remove(elem) - delete(c.cache, path) - } - } -} - -// Clear removes all entries from the cache -func (c *ListDirCache) Clear() { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string]*list.Element) - c.lruList = list.New() -} - -// StatCache implements an LRU cache for stat results -type StatCache struct { - mu sync.RWMutex - cache map[string]*list.Element - lruList *list.List - maxSize int - ttl time.Duration - enabled bool - hitCount uint64 - missCount uint64 -} - -// statCacheItem is the value stored in the LRU list -type statCacheItem struct { - path string - entry *StatCacheEntry -} - -// NewStatCache creates a new stat result cache -func NewStatCache(maxSize int, ttl time.Duration, enabled bool) *StatCache { - if maxSize <= 0 { - maxSize = 5000 - } - if ttl <= 0 { - ttl = 60 * time.Second - } - - return &StatCache{ - cache: make(map[string]*list.Element), - lruList: list.New(), - maxSize: maxSize, - ttl: ttl, - enabled: enabled, - } -} - -// Get retrieves a cached stat result -func (c *StatCache) Get(path string) (*filesystem.FileInfo, bool) { - if !c.enabled { - return nil, false - } - - c.mu.Lock() - defer c.mu.Unlock() - - elem, ok := c.cache[path] - if !ok { - c.missCount++ - return nil, false - } - - item := elem.Value.(*statCacheItem) - - // Check if entry is expired - if time.Since(item.entry.ModTime) > c.ttl { - c.lruList.Remove(elem) - delete(c.cache, path) - c.missCount++ - return nil, false - } - - // Move to front - c.lruList.MoveToFront(elem) - c.hitCount++ - - // Return a copy - info := *item.entry.Info - return &info, true -} - -// Put adds a stat result to the cache -func (c *StatCache) Put(path string, info *filesystem.FileInfo) { - if !c.enabled || info == nil { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - // Check if entry already exists - if elem, ok := c.cache[path]; ok { - item := elem.Value.(*statCacheItem) - item.entry.Info = info - item.entry.ModTime = time.Now() - c.lruList.MoveToFront(elem) - return - } - - // Create new entry - entry := &StatCacheEntry{ - Info: info, - ModTime: time.Now(), - } - - item := &statCacheItem{ - path: path, - entry: entry, - } - - elem := c.lruList.PushFront(item) - c.cache[path] = elem - - // Evict oldest entry if cache is full - if c.lruList.Len() > c.maxSize { - oldest := c.lruList.Back() - if oldest != nil { - c.lruList.Remove(oldest) - oldestItem := oldest.Value.(*statCacheItem) - delete(c.cache, oldestItem.path) - } - } -} - -// Invalidate removes a specific path from the cache -func (c *StatCache) Invalidate(path string) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - if elem, ok := c.cache[path]; ok { - c.lruList.Remove(elem) - delete(c.cache, path) - } -} - -// InvalidatePrefix removes all paths with the given prefix from cache -func (c *StatCache) InvalidatePrefix(prefix string) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - toDelete := make([]string, 0) - for path := range c.cache { - if path == prefix || (len(path) > len(prefix) && path[:len(prefix)] == prefix && path[len(prefix)] == '/') { - toDelete = append(toDelete, path) - } - } - - for _, path := range toDelete { - if elem, ok := c.cache[path]; ok { - c.lruList.Remove(elem) - delete(c.cache, path) - } - } -} - -// Clear removes all entries from the cache -func (c *StatCache) Clear() { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string]*list.Element) - c.lruList = list.New() -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/s3fs/client.go b/third_party/agfs/agfs-server/pkg/plugins/s3fs/client.go deleted file mode 100644 index e47dc64e0..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/s3fs/client.go +++ /dev/null @@ -1,559 +0,0 @@ -package s3fs - -import ( - "bytes" - "context" - "fmt" - "io" - "path/filepath" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go-v2/service/s3/types" - log "github.com/sirupsen/logrus" -) - -type DirectoryMarkerMode string - -const ( - DirectoryMarkerModeNone DirectoryMarkerMode = "none" - DirectoryMarkerModeEmpty DirectoryMarkerMode = "empty" - DirectoryMarkerModeNonEmpty DirectoryMarkerMode = "nonempty" -) - -// S3Client wraps AWS S3 client with helper methods -type S3Client struct { - client *s3.Client - bucket string - region string // AWS region - prefix string // Optional prefix for all keys - directoryMarkerMode DirectoryMarkerMode - disableBatchDelete bool // Disable batch delete for OSS compatibility -} - -// S3Config holds S3 client configuration -type S3Config struct { - Region string - Bucket string - AccessKeyID string - SecretAccessKey string - Endpoint string // Optional custom endpoint (for S3-compatible services) - Prefix string // Optional prefix for all keys - DisableSSL bool // For testing with local S3 - UsePathStyle bool // Whether to use path-style addressing (true) or virtual-host-style (false) - DirectoryMarkerMode DirectoryMarkerMode - DisableBatchDelete bool // Disable batch delete (DeleteObjects) for S3-compatible services -} - -var nonEmptyDirectoryMarkerPayload = []byte{'\n'} - -func normalizeDirectoryMarkerMode(mode DirectoryMarkerMode) DirectoryMarkerMode { - if mode == "" { - return DirectoryMarkerModeEmpty - } - return mode -} - -func isValidDirectoryMarkerMode(mode DirectoryMarkerMode) bool { - switch mode { - case DirectoryMarkerModeNone, DirectoryMarkerModeEmpty, DirectoryMarkerModeNonEmpty: - return true - default: - return false - } -} - -func directoryMarkerPayload(mode DirectoryMarkerMode) ([]byte, bool) { - switch normalizeDirectoryMarkerMode(mode) { - case DirectoryMarkerModeNone: - return nil, false - case DirectoryMarkerModeNonEmpty: - return nonEmptyDirectoryMarkerPayload, true - default: - return []byte{}, true - } -} - -// NewS3Client creates a new S3 client -func NewS3Client(cfg S3Config) (*S3Client, error) { - ctx := context.Background() - - var awsCfg aws.Config - var err error - - // Build AWS config options - opts := []func(*config.LoadOptions) error{ - config.WithRegion(cfg.Region), - } - - // Add credentials if provided - if cfg.AccessKeyID != "" && cfg.SecretAccessKey != "" { - opts = append(opts, config.WithCredentialsProvider( - credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, ""), - )) - } - - awsCfg, err = config.LoadDefaultConfig(ctx, opts...) - if err != nil { - return nil, fmt.Errorf("failed to load AWS config: %w", err) - } - - // Create S3 client options - clientOpts := []func(*s3.Options){} - - // Set custom endpoint if provided (for MinIO, LocalStack, TOS, etc.) - if cfg.Endpoint != "" { - clientOpts = append(clientOpts, func(o *s3.Options) { - o.BaseEndpoint = aws.String(cfg.Endpoint) - // true represent UsePathStyle for MinIO and some S3-compatible services - // false represent VirtualHostStyle for TOS and some S3-compatible services - o.UsePathStyle = cfg.UsePathStyle - }) - } - - client := s3.NewFromConfig(awsCfg, clientOpts...) - - // Verify bucket exists - _, err = client.HeadBucket(ctx, &s3.HeadBucketInput{ - Bucket: aws.String(cfg.Bucket), - }) - if err != nil { - return nil, fmt.Errorf("failed to access bucket %s: %w", cfg.Bucket, err) - } - - log.Infof("[s3fs] Connected to S3 bucket: %s (region: %s)", cfg.Bucket, cfg.Region) - - // Normalize prefix: remove leading and trailing slashes - prefix := strings.Trim(cfg.Prefix, "/") - - return &S3Client{ - client: client, - bucket: cfg.Bucket, - region: cfg.Region, - prefix: prefix, - directoryMarkerMode: normalizeDirectoryMarkerMode(cfg.DirectoryMarkerMode), - disableBatchDelete: cfg.DisableBatchDelete, - }, nil -} - -func (c *S3Client) shouldEnforceParentDirectoryExistence() bool { - return normalizeDirectoryMarkerMode(c.directoryMarkerMode) != DirectoryMarkerModeNone -} - -// buildKey builds the full S3 key with prefix -func (c *S3Client) buildKey(path string) string { - // Normalize path - path = strings.TrimPrefix(path, "/") - - if c.prefix == "" { - return path - } - - if path == "" { - return c.prefix - } - - return c.prefix + "/" + path -} - -// GetObject retrieves an object from S3 -func (c *S3Client) GetObject(ctx context.Context, path string) ([]byte, error) { - key := c.buildKey(path) - - result, err := c.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - }) - if err != nil { - return nil, fmt.Errorf("failed to get object %s: %w", key, err) - } - defer result.Body.Close() - - data, err := io.ReadAll(result.Body) - if err != nil { - return nil, fmt.Errorf("failed to read object body: %w", err) - } - - return data, nil -} - -// GetObjectStream retrieves an object from S3 and returns a stream reader -// The caller is responsible for closing the returned ReadCloser -func (c *S3Client) GetObjectStream(ctx context.Context, path string) (io.ReadCloser, error) { - key := c.buildKey(path) - - result, err := c.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - }) - if err != nil { - return nil, fmt.Errorf("failed to get object %s: %w", key, err) - } - - return result.Body, nil -} - -// GetObjectRange retrieves a byte range from an S3 object -// offset: starting byte position (0-based) -// size: number of bytes to read (-1 for all remaining bytes from offset) -func (c *S3Client) GetObjectRange(ctx context.Context, path string, offset, size int64) ([]byte, error) { - key := c.buildKey(path) - - // Build range header - var rangeHeader string - if size < 0 { - // From offset to end - rangeHeader = fmt.Sprintf("bytes=%d-", offset) - } else { - // Specific range - rangeHeader = fmt.Sprintf("bytes=%d-%d", offset, offset+size-1) - } - - result, err := c.client.GetObject(ctx, &s3.GetObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - Range: aws.String(rangeHeader), - }) - if err != nil { - return nil, fmt.Errorf("failed to get object range %s: %w", key, err) - } - defer result.Body.Close() - - data, err := io.ReadAll(result.Body) - if err != nil { - return nil, fmt.Errorf("failed to read object body: %w", err) - } - - return data, nil -} - -// PutObject uploads an object to S3 -func (c *S3Client) PutObject(ctx context.Context, path string, data []byte) error { - key := c.buildKey(path) - - _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - Body: bytes.NewReader(data), - }) - if err != nil { - return fmt.Errorf("failed to put object %s: %w", key, err) - } - - return nil -} - -// DeleteObject deletes an object from S3 -func (c *S3Client) DeleteObject(ctx context.Context, path string) error { - key := c.buildKey(path) - - _, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - }) - if err != nil { - return fmt.Errorf("failed to delete object %s: %w", key, err) - } - - return nil -} - -// HeadObject checks if an object exists and returns its metadata -func (c *S3Client) HeadObject(ctx context.Context, path string) (*s3.HeadObjectOutput, error) { - key := c.buildKey(path) - - result, err := c.client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - }) - if err != nil { - return nil, err - } - - return result, nil -} - -// S3Object represents an S3 object with metadata -type S3Object struct { - Key string - Size int64 - LastModified time.Time - IsDir bool -} - -// ListObjects lists objects with a given prefix -func (c *S3Client) ListObjects(ctx context.Context, path string) ([]S3Object, error) { - // Normalize path to use as prefix - prefix := c.buildKey(path) - if prefix != "" && !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - - var objects []S3Object - paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{ - Bucket: aws.String(c.bucket), - Prefix: aws.String(prefix), - Delimiter: aws.String("/"), // Only list immediate children - }) - - for paginator.HasMorePages() { - page, err := paginator.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list objects: %w", err) - } - - // Add directories (common prefixes) - for _, commonPrefix := range page.CommonPrefixes { - if commonPrefix.Prefix == nil { - continue - } - - // Remove the search prefix to get relative path - relPath := strings.TrimPrefix(*commonPrefix.Prefix, prefix) - relPath = strings.TrimSuffix(relPath, "/") - - objects = append(objects, S3Object{ - Key: relPath, - Size: 0, - LastModified: time.Now(), - IsDir: true, - }) - } - - // Add files - for _, obj := range page.Contents { - if obj.Key == nil { - continue - } - - // Skip the prefix itself - if *obj.Key == prefix { - continue - } - - // Remove the search prefix to get relative path - relPath := strings.TrimPrefix(*obj.Key, prefix) - - // Skip if this is a directory marker - if strings.HasSuffix(relPath, "/") { - continue - } - - objects = append(objects, S3Object{ - Key: relPath, - Size: aws.ToInt64(obj.Size), - LastModified: aws.ToTime(obj.LastModified), - IsDir: false, - }) - } - } - - return objects, nil -} - -// CreateDirectory creates a directory marker in S3. -// S3 doesn't have real directories; they are represented by object keys ending with "/". -func (c *S3Client) CreateDirectory(ctx context.Context, path string) error { - payload, shouldCreate := directoryMarkerPayload(c.directoryMarkerMode) - if !shouldCreate { - return nil - } - - key := c.buildKey(path) - if !strings.HasSuffix(key, "/") { - key += "/" - } - - _, err := c.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - Body: bytes.NewReader(payload), - }) - if err != nil { - return fmt.Errorf("failed to create directory %s: %w", key, err) - } - - return nil -} - -// DeleteDirectory deletes all objects under a prefix -func (c *S3Client) DeleteDirectory(ctx context.Context, path string) error { - prefix := c.buildKey(path) - if !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - - // List all objects with this prefix - var objectsToDelete []types.ObjectIdentifier - paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{ - Bucket: aws.String(c.bucket), - Prefix: aws.String(prefix), - }) - - for paginator.HasMorePages() { - page, err := paginator.NextPage(ctx) - if err != nil { - return fmt.Errorf("failed to list objects for deletion: %w", err) - } - - for _, obj := range page.Contents { - objectsToDelete = append(objectsToDelete, types.ObjectIdentifier{ - Key: obj.Key, - }) - } - } - - // Delete in batches (S3 allows up to 1000 per request) - batchSize := 1000 - for i := 0; i < len(objectsToDelete); i += batchSize { - end := i + batchSize - if end > len(objectsToDelete) { - end = len(objectsToDelete) - } - - if c.disableBatchDelete { - // Sequential single-object delete for S3-compatible services (e.g., Alibaba Cloud OSS) - // that require Content-MD5 for DeleteObjects but AWS SDK v2 does not send it by default. - for _, obj := range objectsToDelete[i:end] { - _, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{ - Bucket: aws.String(c.bucket), - Key: obj.Key, - }) - if err != nil { - return fmt.Errorf("failed to delete objects: %w", err) - } - } - } else { - _, err := c.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ - Bucket: aws.String(c.bucket), - Delete: &types.Delete{ - Objects: objectsToDelete[i:end], - }, - }) - if err != nil { - return fmt.Errorf("failed to delete objects: %w", err) - } - } - } - - return nil -} - -// ObjectExists checks if an object exists -func (c *S3Client) ObjectExists(ctx context.Context, path string) (bool, error) { - _, err := c.HeadObject(ctx, path) - if err != nil { - // Check if it's a "not found" error - if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "404") { - return false, nil - } - return false, err - } - return true, nil -} - -// DirectoryExists checks if a directory exists (has objects with the prefix) -// Optimized to use a single ListObjectsV2 call -func (c *S3Client) DirectoryExists(ctx context.Context, path string) (bool, error) { - // First check if directory marker exists - dirKey := c.buildKey(path) - if !strings.HasSuffix(dirKey, "/") { - dirKey += "/" - } - - // Try HeadObject to check if directory marker exists - _, err := c.client.HeadObject(ctx, &s3.HeadObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(dirKey), - }) - if err == nil { - // Directory marker exists - return true, nil - } - - // If directory marker doesn't exist, check if there are any objects with this prefix - prefix := dirKey - result, err := c.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ - Bucket: aws.String(c.bucket), - Prefix: aws.String(prefix), - MaxKeys: aws.Int32(1), // Just need to check if any exist - Delimiter: aws.String("/"), - }) - if err != nil { - return false, err - } - - return len(result.Contents) > 0 || len(result.CommonPrefixes) > 0, nil -} - -// CopyObject copies an object within the same bucket -func (c *S3Client) CopyObject(ctx context.Context, srcPath, dstPath string) error { - srcKey := c.buildKey(srcPath) - dstKey := c.buildKey(dstPath) - - _, err := c.client.CopyObject(ctx, &s3.CopyObjectInput{ - Bucket: aws.String(c.bucket), - CopySource: aws.String(c.bucket + "/" + srcKey), - Key: aws.String(dstKey), - }) - if err != nil { - return fmt.Errorf("failed to copy object %s -> %s: %w", srcKey, dstKey, err) - } - return nil -} - -// ListAllObjects lists all objects (recursively) under a given prefix. -// Unlike ListObjects which only lists immediate children, this returns -// every object in the subtree. -func (c *S3Client) ListAllObjects(ctx context.Context, path string) ([]S3Object, error) { - prefix := c.buildKey(path) - if prefix != "" && !strings.HasSuffix(prefix, "/") { - prefix += "/" - } - - var objects []S3Object - paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{ - Bucket: aws.String(c.bucket), - Prefix: aws.String(prefix), - // No Delimiter — list all objects recursively - }) - - for paginator.HasMorePages() { - page, err := paginator.NextPage(ctx) - if err != nil { - return nil, fmt.Errorf("failed to list all objects: %w", err) - } - - for _, obj := range page.Contents { - if obj.Key == nil { - continue - } - relPath := strings.TrimPrefix(*obj.Key, prefix) - isDir := strings.HasSuffix(relPath, "/") - objects = append(objects, S3Object{ - Key: relPath, - Size: aws.ToInt64(obj.Size), - LastModified: aws.ToTime(obj.LastModified), - IsDir: isDir, - }) - } - } - - return objects, nil -} - -// getParentPath returns the parent directory path -func getParentPath(path string) string { - if path == "" || path == "/" { - return "" - } - parent := filepath.Dir(path) - if parent == "." { - return "" - } - return parent -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/s3fs/marker_mode_test.go b/third_party/agfs/agfs-server/pkg/plugins/s3fs/marker_mode_test.go deleted file mode 100644 index b9061a175..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/s3fs/marker_mode_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package s3fs - -import "testing" - -func TestNormalizeDirectoryMarkerModeConfigDefaultsToEmpty(t *testing.T) { - mode, err := normalizeDirectoryMarkerModeConfig(map[string]interface{}{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if mode != DirectoryMarkerModeEmpty { - t.Fatalf("expected default mode %q, got %q", DirectoryMarkerModeEmpty, mode) - } -} - -func TestNormalizeDirectoryMarkerModeConfigRejectsUnknownMode(t *testing.T) { - _, err := normalizeDirectoryMarkerModeConfig(map[string]interface{}{ - "directory_marker_mode": "mystery", - }) - if err == nil { - t.Fatal("expected unknown mode error, got nil") - } -} - -func TestDirectoryMarkerPayload(t *testing.T) { - payload, shouldCreate := directoryMarkerPayload(DirectoryMarkerModeNone) - if shouldCreate { - t.Fatal("expected none mode to skip marker creation") - } - if payload != nil { - t.Fatalf("expected nil payload for none mode, got %v", payload) - } - - payload, shouldCreate = directoryMarkerPayload(DirectoryMarkerModeEmpty) - if !shouldCreate { - t.Fatal("expected empty mode to create marker") - } - if len(payload) != 0 { - t.Fatalf("expected empty marker payload, got %d bytes", len(payload)) - } - - payload, shouldCreate = directoryMarkerPayload(DirectoryMarkerModeNonEmpty) - if !shouldCreate { - t.Fatal("expected nonempty mode to create marker") - } - if len(payload) != 1 || payload[0] != '\n' { - t.Fatalf("expected newline marker payload, got %v", payload) - } -} - -func TestShouldEnforceParentDirectoryExistence(t *testing.T) { - client := &S3Client{directoryMarkerMode: DirectoryMarkerModeNone} - if client.shouldEnforceParentDirectoryExistence() { - t.Fatal("expected none mode to skip parent directory enforcement") - } - - client.directoryMarkerMode = DirectoryMarkerModeEmpty - if !client.shouldEnforceParentDirectoryExistence() { - t.Fatal("expected empty mode to enforce parent directories") - } - - client.directoryMarkerMode = DirectoryMarkerModeNonEmpty - if !client.shouldEnforceParentDirectoryExistence() { - t.Fatal("expected nonempty mode to enforce parent directories") - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/s3fs/s3fs.go b/third_party/agfs/agfs-server/pkg/plugins/s3fs/s3fs.go deleted file mode 100644 index 76258849b..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/s3fs/s3fs.go +++ /dev/null @@ -1,1070 +0,0 @@ -package s3fs - -import ( - "context" - "fmt" - "io" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "s3fs" -) - -// S3FS implements FileSystem interface using AWS S3 as backend -type S3FS struct { - client *S3Client - mu sync.RWMutex - pluginName string - - // Caches for performance optimization - dirCache *ListDirCache - statCache *StatCache -} - -// CacheConfig holds cache configuration -type CacheConfig struct { - Enabled bool - DirCacheTTL time.Duration - StatCacheTTL time.Duration - MaxSize int -} - -// DefaultCacheConfig returns default cache configuration -func DefaultCacheConfig() CacheConfig { - return CacheConfig{ - Enabled: true, - DirCacheTTL: 30 * time.Second, - StatCacheTTL: 60 * time.Second, - MaxSize: 1000, - } -} - -// NewS3FS creates a new S3-backed file system -func NewS3FS(cfg S3Config) (*S3FS, error) { - return NewS3FSWithCache(cfg, DefaultCacheConfig()) -} - -// NewS3FSWithCache creates a new S3-backed file system with cache configuration -func NewS3FSWithCache(cfg S3Config, cacheCfg CacheConfig) (*S3FS, error) { - client, err := NewS3Client(cfg) - if err != nil { - return nil, fmt.Errorf("failed to create S3 client: %w", err) - } - - return &S3FS{ - client: client, - pluginName: PluginName, - dirCache: NewListDirCache(cacheCfg.MaxSize, cacheCfg.DirCacheTTL, cacheCfg.Enabled), - statCache: NewStatCache(cacheCfg.MaxSize*5, cacheCfg.StatCacheTTL, cacheCfg.Enabled), - }, nil -} - -func (fs *S3FS) Create(path string) error { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if file already exists - exists, err := fs.client.ObjectExists(ctx, path) - if err != nil { - return fmt.Errorf("failed to check if file exists: %w", err) - } - if exists { - return fmt.Errorf("file already exists: %s", path) - } - - // Check if parent directory exists - parent := getParentPath(path) - if parent != "" && fs.client.shouldEnforceParentDirectoryExistence() { - dirExists, err := fs.client.DirectoryExists(ctx, parent) - if err != nil { - return fmt.Errorf("failed to check parent directory: %w", err) - } - if !dirExists { - return fmt.Errorf("parent directory does not exist: %s", parent) - } - } - - // Create empty file - err = fs.client.PutObject(ctx, path, []byte{}) - if err == nil { - // Invalidate caches - fs.dirCache.Invalidate(parent) - fs.statCache.Invalidate(path) - } - return err -} - -func (fs *S3FS) Mkdir(path string, perm uint32) error { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if directory already exists - exists, err := fs.client.DirectoryExists(ctx, path) - if err != nil { - return fmt.Errorf("failed to check if directory exists: %w", err) - } - if exists { - return fmt.Errorf("directory already exists: %s", path) - } - - // Check if parent directory exists - parent := getParentPath(path) - if parent != "" && fs.client.shouldEnforceParentDirectoryExistence() { - dirExists, err := fs.client.DirectoryExists(ctx, parent) - if err != nil { - return fmt.Errorf("failed to check parent directory: %w", err) - } - if !dirExists { - return fmt.Errorf("parent directory does not exist: %s", parent) - } - } - - // Create directory marker - err = fs.client.CreateDirectory(ctx, path) - if err == nil { - // Invalidate caches - fs.dirCache.Invalidate(parent) - fs.statCache.Invalidate(path) - } - return err -} - -func (fs *S3FS) Remove(path string) error { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.Lock() - defer fs.mu.Unlock() - - parent := getParentPath(path) - - // Check if it's a file - exists, err := fs.client.ObjectExists(ctx, path) - if err != nil { - return fmt.Errorf("failed to check if file exists: %w", err) - } - - if exists { - // It's a file, delete it - err = fs.client.DeleteObject(ctx, path) - if err == nil { - fs.dirCache.Invalidate(parent) - fs.statCache.Invalidate(path) - } - return err - } - - // Check if it's a directory - dirExists, err := fs.client.DirectoryExists(ctx, path) - if err != nil { - return fmt.Errorf("failed to check if directory exists: %w", err) - } - - if !dirExists { - return filesystem.ErrNotFound - } - - // Check if directory is empty - objects, err := fs.client.ListObjects(ctx, path) - if err != nil { - return fmt.Errorf("failed to list directory: %w", err) - } - - if len(objects) > 0 { - return fmt.Errorf("directory not empty: %s", path) - } - - // Delete directory marker - err = fs.client.DeleteObject(ctx, path+"/") - if err == nil { - fs.dirCache.Invalidate(parent) - fs.dirCache.Invalidate(path) - fs.statCache.Invalidate(path) - } - return err -} - -func (fs *S3FS) RemoveAll(path string) error { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.Lock() - defer fs.mu.Unlock() - - err := fs.client.DeleteDirectory(ctx, path) - if err == nil { - parent := getParentPath(path) - fs.dirCache.Invalidate(parent) - fs.dirCache.InvalidatePrefix(path) - fs.statCache.InvalidatePrefix(path) - } - return err -} - -func (fs *S3FS) Read(path string, offset int64, size int64) ([]byte, error) { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Use S3 Range request for efficient partial reads - if offset > 0 || size > 0 { - data, err := fs.client.GetObjectRange(ctx, path, offset, size) - if err != nil { - if strings.Contains(err.Error(), "NoSuchKey") || strings.Contains(err.Error(), "NotFound") { - return nil, filesystem.ErrNotFound - } - return nil, err - } - return data, nil - } - - // Full file read - data, err := fs.client.GetObject(ctx, path) - if err != nil { - if strings.Contains(err.Error(), "NoSuchKey") || strings.Contains(err.Error(), "NotFound") { - return nil, filesystem.ErrNotFound - } - return nil, err - } - - return data, nil -} - -func (fs *S3FS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.Lock() - defer fs.mu.Unlock() - - // S3 is an object store - it doesn't support offset writes - // Only full object replacement is supported - if offset >= 0 && offset != 0 { - return 0, fmt.Errorf("S3 does not support offset writes") - } - - // Skip directory checks for performance - S3 PutObject will overwrite anyway - // The path ending with "/" check is sufficient for directory detection - if strings.HasSuffix(path, "/") { - return 0, fmt.Errorf("is a directory: %s", path) - } - - // Write to S3 directly - S3 will create parent "directories" implicitly - err := fs.client.PutObject(ctx, path, data) - if err != nil { - return 0, err - } - - // Invalidate caches - parent := getParentPath(path) - fs.dirCache.Invalidate(parent) - fs.statCache.Invalidate(path) - - return int64(len(data)), nil -} - -func (fs *S3FS) ReadDir(path string) ([]filesystem.FileInfo, error) { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Check cache first - if cached, ok := fs.dirCache.Get(path); ok { - return cached, nil - } - - // Check if directory exists - if path != "" { - exists, err := fs.client.DirectoryExists(ctx, path) - if err != nil { - return nil, fmt.Errorf("failed to check directory: %w", err) - } - if !exists { - return nil, filesystem.ErrNotFound - } - } - - // List objects - objects, err := fs.client.ListObjects(ctx, path) - if err != nil { - return nil, err - } - - var files []filesystem.FileInfo - for _, obj := range objects { - mode := uint32(0644) - if obj.IsDir { - mode = 0755 - } - files = append(files, filesystem.FileInfo{ - Name: obj.Key, - Size: obj.Size, - Mode: mode, - ModTime: obj.LastModified, - IsDir: obj.IsDir, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "s3", - }, - }) - } - - // Cache the result - fs.dirCache.Put(path, files) - - return files, nil -} - -func (fs *S3FS) Stat(path string) (*filesystem.FileInfo, error) { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Special case for root - if path == "" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "s3", - Content: map[string]string{ - "region": fs.client.region, - "bucket": fs.client.bucket, - "prefix": fs.client.prefix, - }, - }, - }, nil - } - - // Check cache first - if cached, ok := fs.statCache.Get(path); ok { - return cached, nil - } - - // Try as file first - head, err := fs.client.HeadObject(ctx, path) - if err == nil { - info := &filesystem.FileInfo{ - Name: filepath.Base(path), - Size: aws.ToInt64(head.ContentLength), - Mode: 0644, - ModTime: aws.ToTime(head.LastModified), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "s3", - Content: map[string]string{ - "region": fs.client.region, - "bucket": fs.client.bucket, - "prefix": fs.client.prefix, - }, - }, - } - fs.statCache.Put(path, info) - return info, nil - } - - // Try as directory - dirExists, err := fs.client.DirectoryExists(ctx, path) - if err != nil { - return nil, fmt.Errorf("failed to check directory: %w", err) - } - - if dirExists { - info := &filesystem.FileInfo{ - Name: filepath.Base(path), - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "s3", - Content: map[string]string{ - "region": fs.client.region, - "bucket": fs.client.bucket, - "prefix": fs.client.prefix, - }, - }, - } - fs.statCache.Put(path, info) - return info, nil - } - - return nil, filesystem.ErrNotFound -} - -func (fs *S3FS) Rename(oldPath, newPath string) error { - oldPath = filesystem.NormalizeS3Key(oldPath) - newPath = filesystem.NormalizeS3Key(newPath) - ctx := context.Background() - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Try as file first - fileExists, err := fs.client.ObjectExists(ctx, oldPath) - if err != nil { - return fmt.Errorf("failed to check source: %w", err) - } - - if fileExists { - return fs.renameSingleObject(ctx, oldPath, newPath) - } - - // Try as directory - dirExists, err := fs.client.DirectoryExists(ctx, oldPath) - if err != nil { - return fmt.Errorf("failed to check source directory: %w", err) - } - if !dirExists { - return filesystem.ErrNotFound - } - - return fs.renameDirectory(ctx, oldPath, newPath) -} - -// renameSingleObject moves a single S3 object via copy + delete. -func (fs *S3FS) renameSingleObject(ctx context.Context, oldPath, newPath string) error { - if err := fs.client.CopyObject(ctx, oldPath, newPath); err != nil { - return fmt.Errorf("failed to copy source: %w", err) - } - - if err := fs.client.DeleteObject(ctx, oldPath); err != nil { - return fmt.Errorf("failed to delete source: %w", err) - } - - oldParent := getParentPath(oldPath) - newParent := getParentPath(newPath) - fs.dirCache.Invalidate(oldParent) - fs.dirCache.Invalidate(newParent) - fs.statCache.Invalidate(oldPath) - fs.statCache.Invalidate(newPath) - - return nil -} - -// renameDirectory moves an entire directory subtree by copying every object -// under oldPath to newPath and then deleting the originals. -func (fs *S3FS) renameDirectory(ctx context.Context, oldPath, newPath string) error { - // List every object (recursively) under oldPath - objects, err := fs.client.ListAllObjects(ctx, oldPath) - if err != nil { - return fmt.Errorf("failed to list source directory: %w", err) - } - - // Copy each object to the new prefix - for _, obj := range objects { - srcRel := obj.Key // relative to oldPath - if err := fs.client.CopyObject(ctx, oldPath+"/"+srcRel, newPath+"/"+srcRel); err != nil { - return fmt.Errorf("failed to copy %s: %w", srcRel, err) - } - } - - // Create the new directory marker - if err := fs.client.CreateDirectory(ctx, newPath); err != nil { - // Ignore if already exists (implicit from copied children) - log.Debugf("[s3fs] CreateDirectory %s (may already exist): %v", newPath, err) - } - - // Delete old directory tree (marker + all children) - if err := fs.client.DeleteDirectory(ctx, oldPath); err != nil { - return fmt.Errorf("failed to delete source directory: %w", err) - } - - // Invalidate caches broadly - oldParent := getParentPath(oldPath) - newParent := getParentPath(newPath) - fs.dirCache.Invalidate(oldParent) - fs.dirCache.Invalidate(newParent) - fs.dirCache.InvalidatePrefix(oldPath) - fs.dirCache.InvalidatePrefix(newPath) - fs.statCache.InvalidatePrefix(oldPath) - fs.statCache.InvalidatePrefix(newPath) - - return nil -} - -func (fs *S3FS) Chmod(path string, mode uint32) error { - // S3 doesn't support Unix permissions - // This is a no-op for compatibility - return nil -} - -func (fs *S3FS) Open(path string) (io.ReadCloser, error) { - data, err := fs.Read(path, 0, -1) - if err != nil && err != io.EOF { - return nil, err - } - return io.NopCloser(strings.NewReader(string(data))), nil -} - -func (fs *S3FS) OpenWrite(path string) (io.WriteCloser, error) { - return &s3fsWriter{fs: fs, path: path}, nil -} - -type s3fsWriter struct { - fs *S3FS - path string - buf []byte -} - -func (w *s3fsWriter) Write(p []byte) (n int, err error) { - w.buf = append(w.buf, p...) - return len(p), nil -} - -func (w *s3fsWriter) Close() error { - _, err := w.fs.Write(w.path, w.buf, -1, filesystem.WriteFlagCreate|filesystem.WriteFlagTruncate) - return err -} - -// S3FSPlugin wraps S3FS as a plugin -type S3FSPlugin struct { - fs *S3FS - config map[string]interface{} -} - -// NewS3FSPlugin creates a new S3FS plugin -func NewS3FSPlugin() *S3FSPlugin { - return &S3FSPlugin{} -} - -func (p *S3FSPlugin) Name() string { - return PluginName -} - -func normalizeDirectoryMarkerModeConfig(cfg map[string]interface{}) (DirectoryMarkerMode, error) { - rawMode, exists := cfg["directory_marker_mode"] - if !exists { - return DirectoryMarkerModeEmpty, nil - } - - modeString, ok := rawMode.(string) - if !ok { - return "", fmt.Errorf("directory_marker_mode must be a string") - } - modeValue := strings.ToLower(strings.TrimSpace(modeString)) - mode := DirectoryMarkerMode(modeValue) - if !isValidDirectoryMarkerMode(mode) { - return "", fmt.Errorf( - "directory_marker_mode must be one of: %s, %s, %s", - DirectoryMarkerModeNone, - DirectoryMarkerModeEmpty, - DirectoryMarkerModeNonEmpty, - ) - } - - return mode, nil -} - -func (p *S3FSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{ - "bucket", "region", "access_key_id", "secret_access_key", "endpoint", "prefix", "disable_ssl", "mount_path", - "cache_enabled", "cache_ttl", "stat_cache_ttl", "cache_max_size", "use_path_style", - "directory_marker_mode", "disable_batch_delete", - } - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate bucket (required) - if _, err := config.RequireString(cfg, "bucket"); err != nil { - return err - } - - // Validate optional string parameters - for _, key := range []string{"region", "access_key_id", "secret_access_key", "endpoint", "prefix"} { - if err := config.ValidateStringType(cfg, key); err != nil { - return err - } - } - if err := config.ValidateStringType(cfg, "directory_marker_mode"); err != nil { - return err - } - - // Validate disable_ssl (optional boolean) - if err := config.ValidateBoolType(cfg, "disable_ssl"); err != nil { - return err - } - - // Validate use_path_style (optional boolean) - if err := config.ValidateBoolType(cfg, "use_path_style"); err != nil { - return err - } - - // Validate cache_enabled (optional boolean) - if err := config.ValidateBoolType(cfg, "cache_enabled"); err != nil { - return err - } - - if _, err := normalizeDirectoryMarkerModeConfig(cfg); err != nil { - return err - } - - return nil -} - -func (p *S3FSPlugin) Initialize(config map[string]interface{}) error { - p.config = config - - directoryMarkerMode, err := normalizeDirectoryMarkerModeConfig(config) - if err != nil { - return err - } - - // Parse S3 configuration - cfg := S3Config{ - Region: getStringConfig(config, "region", "us-east-1"), - Bucket: getStringConfig(config, "bucket", ""), - AccessKeyID: getStringConfig(config, "access_key_id", ""), - SecretAccessKey: getStringConfig(config, "secret_access_key", ""), - Endpoint: getStringConfig(config, "endpoint", ""), - Prefix: getStringConfig(config, "prefix", ""), - DisableSSL: getBoolConfig(config, "disable_ssl", false), - UsePathStyle: getBoolConfig(config, "use_path_style", true), - DirectoryMarkerMode: directoryMarkerMode, - DisableBatchDelete: getBoolConfig(config, "disable_batch_delete", false), - } - - if cfg.Bucket == "" { - return fmt.Errorf("bucket name is required") - } - - // Parse cache configuration - cacheCfg := CacheConfig{ - Enabled: getBoolConfig(config, "cache_enabled", true), - DirCacheTTL: getDurationConfig(config, "cache_ttl", 30*time.Second), - StatCacheTTL: getDurationConfig(config, "stat_cache_ttl", 60*time.Second), - MaxSize: getIntConfig(config, "cache_max_size", 1000), - } - - // Create S3FS instance with cache - fs, err := NewS3FSWithCache(cfg, cacheCfg) - if err != nil { - return fmt.Errorf("failed to initialize s3fs: %w", err) - } - p.fs = fs - - log.Infof( - "[s3fs] Initialized with bucket: %s, region: %s, cache: %v, directory_marker_mode: %s", - cfg.Bucket, - cfg.Region, - cacheCfg.Enabled, - cfg.DirectoryMarkerMode, - ) - return nil -} - -func (p *S3FSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *S3FSPlugin) GetReadme() string { - return getReadme() -} - -func (p *S3FSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "bucket", - Type: "string", - Required: true, - Default: "", - Description: "S3 bucket name", - }, - { - Name: "region", - Type: "string", - Required: false, - Default: "us-east-1", - Description: "AWS region", - }, - { - Name: "access_key_id", - Type: "string", - Required: false, - Default: "", - Description: "AWS access key ID (uses env AWS_ACCESS_KEY_ID if not provided)", - }, - { - Name: "secret_access_key", - Type: "string", - Required: false, - Default: "", - Description: "AWS secret access key (uses env AWS_SECRET_ACCESS_KEY if not provided)", - }, - { - Name: "endpoint", - Type: "string", - Required: false, - Default: "", - Description: "Custom S3 endpoint for S3-compatible services (e.g., MinIO)", - }, - { - Name: "prefix", - Type: "string", - Required: false, - Default: "", - Description: "Key prefix for namespace isolation", - }, - { - Name: "disable_ssl", - Type: "bool", - Required: false, - Default: "false", - Description: "Disable SSL for S3 connections", - }, - { - Name: "use_path_style", - Type: "bool", - Required: false, - Default: "true", - Description: "Whether to use path-style addressing (true) or virtual-host-style (false). Set false for TOS and other VirtualHostStyle backends.", - }, - { - Name: "directory_marker_mode", - Type: "string", - Required: false, - Default: "empty", - Description: "How to persist directory markers: 'none' skips marker creation, 'empty' writes a zero-byte marker, and 'nonempty' writes a non-empty payload.", - }, - { - Name: "cache_enabled", - Type: "bool", - Required: false, - Default: "true", - Description: "Enable caching for directory listings and stat results", - }, - { - Name: "cache_ttl", - Type: "string", - Required: false, - Default: "30s", - Description: "TTL for directory listing cache (e.g., '30s', '1m')", - }, - { - Name: "stat_cache_ttl", - Type: "string", - Required: false, - Default: "60s", - Description: "TTL for stat result cache (e.g., '60s', '2m')", - }, - { - Name: "cache_max_size", - Type: "int", - Required: false, - Default: "1000", - Description: "Maximum number of entries in each cache", - }, - } -} - -func (p *S3FSPlugin) Shutdown() error { - return nil -} - -func getReadme() string { - return `S3FS Plugin - AWS S3-backed File System - -This plugin provides a file system backed by AWS S3 object storage. - -FEATURES: - - Store files and directories in AWS S3 - - Support for S3-compatible services (MinIO, LocalStack, etc.) - - Full POSIX-like file system operations - - Streaming support for efficient large file handling - - Automatic directory handling - - Optional key prefix for namespace isolation - -CONFIGURATION: - - AWS S3: - [plugins.s3fs] - enabled = true - path = "/s3fs" - - [plugins.s3fs.config] - region = "us-east-1" - bucket = "my-bucket" - access_key_id = "AKIAIOSFODNN7EXAMPLE" - secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" - directory_marker_mode = "empty" - prefix = "agfs/" # Optional: all keys will be prefixed with this - - S3-Compatible Service (MinIO, LocalStack): - [plugins.s3fs] - enabled = true - path = "/s3fs" - - [plugins.s3fs.config] - region = "us-east-1" - bucket = "my-bucket" - access_key_id = "minioadmin" - secret_access_key = "minioadmin" - endpoint = "http://localhost:9000" - disable_ssl = true - - Multiple S3 Buckets: - [plugins.s3fs_prod] - enabled = true - path = "/s3/prod" - - [plugins.s3fs_prod.config] - region = "us-east-1" - bucket = "production-bucket" - access_key_id = "..." - secret_access_key = "..." - - [plugins.s3fs_dev] - enabled = true - path = "/s3/dev" - - [plugins.s3fs_dev.config] - region = "us-west-2" - bucket = "development-bucket" - access_key_id = "..." - secret_access_key = "..." - -USAGE: - - Create a directory: - agfs mkdir /s3fs/data - - Create a file: - agfs write /s3fs/data/file.txt "Hello, S3!" - - Read a file: - agfs cat /s3fs/data/file.txt - - Stream a large file (memory efficient): - agfs cat --stream /s3fs/data/large-video.mp4 > output.mp4 - - List directory: - agfs ls /s3fs/data - - Remove file: - agfs rm /s3fs/data/file.txt - - Remove directory (must be empty): - agfs rm /s3fs/data - - Remove directory recursively: - agfs rm -r /s3fs/data - -EXAMPLES: - - # Basic file operations - agfs:/> mkdir /s3fs/documents - agfs:/> echo "Important data" > /s3fs/documents/report.txt - agfs:/> cat /s3fs/documents/report.txt - Important data - - # List contents - agfs:/> ls /s3fs/documents - report.txt - - # Move/rename - agfs:/> mv /s3fs/documents/report.txt /s3fs/documents/report-2024.txt - - # Stream large files efficiently - agfs:/> cat --stream /s3fs/videos/movie.mp4 > local-movie.mp4 - # Streams in 256KB chunks, minimal memory usage - -NOTES: - - S3 doesn't have real directories; they are simulated with "/" in object keys - - directory_marker_mode = "empty" is the default and preserves empty-directory semantics with zero-byte markers - - Use directory_marker_mode = "nonempty" for backends such as TOS that reject zero-byte directory markers - - Use directory_marker_mode = "none" for pure prefix-style behavior when you do not need persisted empty directories - - Use --stream flag for large files to minimize memory usage (256KB chunks) - - Permissions (chmod) are not supported by S3 - - Atomic operations are limited by S3's eventual consistency model - - Streaming is automatically used when accessing via Python SDK with stream=True - -USE CASES: - - Cloud-native file storage - - Backup and archival - - Sharing files across distributed systems - - Cost-effective long-term storage - - Integration with AWS services - -ADVANTAGES: - - Unlimited storage capacity - - High durability (99.999999999%) - - Geographic redundancy - - Pay-per-use pricing - - Efficient streaming for large files with minimal memory footprint - - Versioning and lifecycle policies (via S3 bucket settings) -` -} - -// Helper functions -func getStringConfig(config map[string]interface{}, key, defaultValue string) string { - if val, ok := config[key].(string); ok && val != "" { - return val - } - return defaultValue -} - -func getBoolConfig(config map[string]interface{}, key string, defaultValue bool) bool { - if val, ok := config[key].(bool); ok { - return val - } - return defaultValue -} - -func getIntConfig(config map[string]interface{}, key string, defaultValue int) int { - if val, ok := config[key].(int); ok { - return val - } - if val, ok := config[key].(float64); ok { - return int(val) - } - return defaultValue -} - -func getDurationConfig(config map[string]interface{}, key string, defaultValue time.Duration) time.Duration { - // Try string format like "30s", "1m", "1h" - if val, ok := config[key].(string); ok && val != "" { - if d, err := time.ParseDuration(val); err == nil { - return d - } - } - // Try numeric (seconds) - if val, ok := config[key].(int); ok { - return time.Duration(val) * time.Second - } - if val, ok := config[key].(float64); ok { - return time.Duration(val) * time.Second - } - return defaultValue -} - -// s3StreamReader implements filesystem.StreamReader for S3 objects -type s3StreamReader struct { - body io.ReadCloser - chunkSize int64 - closed bool - mu sync.Mutex -} - -// ReadChunk reads the next chunk from the S3 object stream -func (r *s3StreamReader) ReadChunk(timeout time.Duration) ([]byte, bool, error) { - r.mu.Lock() - defer r.mu.Unlock() - - if r.closed { - return nil, true, io.EOF - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // Prepare buffer for reading - buf := make([]byte, r.chunkSize) - - // Channel to receive read result - type readResult struct { - n int - err error - } - resultCh := make(chan readResult, 1) - - // Read in goroutine to support timeout - go func() { - n, err := r.body.Read(buf) - resultCh <- readResult{n: n, err: err} - }() - - // Wait for read or timeout - select { - case result := <-resultCh: - if result.err == io.EOF { - // End of file reached - if result.n > 0 { - return buf[:result.n], true, nil - } - return nil, true, io.EOF - } - if result.err != nil { - return nil, false, result.err - } - return buf[:result.n], false, nil - - case <-ctx.Done(): - // Timeout occurred - return nil, false, fmt.Errorf("read timeout: %w", ctx.Err()) - } -} - -// Close closes the S3 object stream -func (r *s3StreamReader) Close() error { - r.mu.Lock() - defer r.mu.Unlock() - - if r.closed { - return nil - } - - r.closed = true - return r.body.Close() -} - -// OpenStream opens a stream for reading an S3 object -// This implements the filesystem.Streamer interface -func (fs *S3FS) OpenStream(path string) (filesystem.StreamReader, error) { - path = filesystem.NormalizeS3Key(path) - ctx := context.Background() - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Get streaming reader from S3 - body, err := fs.client.GetObjectStream(ctx, path) - if err != nil { - if strings.Contains(err.Error(), "NoSuchKey") || strings.Contains(err.Error(), "NotFound") { - return nil, filesystem.ErrNotFound - } - return nil, err - } - - // Create stream reader with 256KB chunk size (balanced for S3) - return &s3StreamReader{ - body: body, - chunkSize: 256 * 1024, // 256KB chunks - closed: false, - }, nil -} - -// Ensure S3FSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*S3FSPlugin)(nil) -var _ filesystem.FileSystem = (*S3FS)(nil) -var _ filesystem.Streamer = (*S3FS)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugins/serverinfofs/README.md b/third_party/agfs/agfs-server/pkg/plugins/serverinfofs/README.md deleted file mode 100644 index a38fda40b..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/serverinfofs/README.md +++ /dev/null @@ -1,44 +0,0 @@ -ServerInfoFS Plugin - Server Metadata and Information - -This plugin provides runtime information about the AGFS server. - -MOUNT: - agfs:/> mount serverinfofs /info - -USAGE: - View server version: - cat /version - - View server uptime: - cat /uptime - - View server info: - cat /info - -FILES: - /version - Server version information - /uptime - Server uptime since start - /info - Complete server information (JSON) - /README - This file - -EXAMPLES: - # Check server version - agfs:/> cat /serverinfofs/version - 1.0.0 - - # Check uptime - agfs:/> cat /serverinfofs/uptime - Server uptime: 5m30s - - # Get complete info - agfs:/> cat /serverinfofs/server_info - { - "version": "1.0.0", - "uptime": "5m30s", - "go_version": "go1.21", - ... - } - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/serverinfofs/serverinfofs.go b/third_party/agfs/agfs-server/pkg/plugins/serverinfofs/serverinfofs.go deleted file mode 100644 index 23932564b..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/serverinfofs/serverinfofs.go +++ /dev/null @@ -1,398 +0,0 @@ -package serverinfofs - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "runtime" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" -) - -// ServerInfoFSPlugin provides server metadata and information -type ServerInfoFSPlugin struct { - startTime time.Time - version string - trafficMonitor TrafficStatsProvider -} - -// TrafficStatsProvider provides traffic statistics -type TrafficStatsProvider interface { - GetStats() interface{} -} - -// NewServerInfoFSPlugin creates a new ServerInfoFS plugin -func NewServerInfoFSPlugin() *ServerInfoFSPlugin { - return &ServerInfoFSPlugin{ - startTime: time.Now(), - version: "1.0.0", - } -} - -// SetTrafficMonitor sets the traffic monitor for the plugin -func (p *ServerInfoFSPlugin) SetTrafficMonitor(tm TrafficStatsProvider) { - p.trafficMonitor = tm -} - -func (p *ServerInfoFSPlugin) Name() string { - return "serverinfofs" -} - -func (p *ServerInfoFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"version", "mount_path"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate version if provided - if err := config.ValidateStringType(cfg, "version"); err != nil { - return err - } - return nil -} - -func (p *ServerInfoFSPlugin) Initialize(config map[string]interface{}) error { - if config != nil { - if v, ok := config["version"].(string); ok { - p.version = v - } - } - return nil -} - -func (p *ServerInfoFSPlugin) GetFileSystem() filesystem.FileSystem { - return &serverInfoFS{plugin: p} -} - -func (p *ServerInfoFSPlugin) GetReadme() string { - return `ServerInfoFS Plugin - Server Metadata and Information - -This plugin provides runtime information about the AGFS server. - -USAGE: - View server version: - cat /version - - View server uptime: - cat /uptime - - View server info: - cat /info - - View real-time traffic: - cat /traffic - -FILES: - /version - Server version information - /uptime - Server uptime since start - /info - Complete server information (JSON) - /stats - Runtime statistics (goroutines, memory) - /traffic - Real-time network traffic statistics - /README - This file - -EXAMPLES: - # Check server version - agfs:/> cat /serverinfofs/version - 1.0.0 - - # Check uptime - agfs:/> cat /serverinfofs/uptime - Server uptime: 5m30s - - # Get complete info - agfs:/> cat /serverinfofs/server_info - { - "version": "1.0.0", - "uptime": "5m30s", - "go_version": "go1.21", - ... - } - - # View real-time traffic - agfs:/> cat /serverinfofs/traffic - { - "downstream_bps": 2621440, - "upstream_bps": 1258291, - "peak_downstream_bps": 11010048, - "peak_upstream_bps": 5452595, - "total_download_bytes": 1073741824, - "total_upload_bytes": 536870912, - "uptime_seconds": 3600 - } -` -} - -func (p *ServerInfoFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{} -} - -func (p *ServerInfoFSPlugin) Shutdown() error { - return nil -} - -// serverInfoFS implements the FileSystem interface for server metadata -type serverInfoFS struct { - plugin *ServerInfoFSPlugin -} - -// Virtual files in serverinfofs -const ( - fileServerInfo = "/server_info" - fileUptime = "/uptime" - fileVersion = "/version" - fileStats = "/stats" - fileTraffic = "/traffic" - fileReadme = "/README" -) - -func (fs *serverInfoFS) isValidPath(path string) bool { - switch path { - case "/", fileServerInfo, fileUptime, fileVersion, fileStats, fileTraffic, fileReadme: - return true - default: - return false - } -} - -func (fs *serverInfoFS) getServerInfo() map[string]interface{} { - uptime := time.Since(fs.plugin.startTime) - var m runtime.MemStats - runtime.ReadMemStats(&m) - - return map[string]interface{}{ - "version": fs.plugin.version, - "uptime": uptime.String(), - "startTime": fs.plugin.startTime.Format(time.RFC3339), - "goVersion": runtime.Version(), - "numCPU": runtime.NumCPU(), - "numGoroutine": runtime.NumGoroutine(), - "memory": map[string]interface{}{ - "alloc": m.Alloc, - "totalAlloc": m.TotalAlloc, - "sys": m.Sys, - "numGC": m.NumGC, - }, - } -} - -func (fs *serverInfoFS) Read(path string, offset int64, size int64) ([]byte, error) { - if !fs.isValidPath(path) { - return nil, filesystem.NewNotFoundError("read", path) - } - - if path == "/" { - return nil, fmt.Errorf("is a directory: %s", path) - } - - var data []byte - var err error - - switch path { - case fileServerInfo: - info := fs.getServerInfo() - data, err = json.MarshalIndent(info, "", " ") - if err != nil { - return nil, err - } - - case fileUptime: - uptime := time.Since(fs.plugin.startTime) - data = []byte(uptime.String()) - - case fileVersion: - data = []byte(fs.plugin.version) - - case fileStats: - var m runtime.MemStats - runtime.ReadMemStats(&m) - stats := map[string]interface{}{ - "goroutines": runtime.NumGoroutine(), - "memory": map[string]interface{}{ - "alloc": m.Alloc, - "totalAlloc": m.TotalAlloc, - "sys": m.Sys, - "numGC": m.NumGC, - }, - } - data, err = json.MarshalIndent(stats, "", " ") - if err != nil { - return nil, err - } - - case fileTraffic: - if fs.plugin.trafficMonitor == nil { - data = []byte("Traffic monitoring not available") - } else { - stats := fs.plugin.trafficMonitor.GetStats() - data, err = json.MarshalIndent(stats, "", " ") - if err != nil { - return nil, err - } - } - - case fileReadme: - data = []byte(fs.plugin.GetReadme()) - - default: - return nil, filesystem.NewNotFoundError("read", path) - } - - // if data is not ended by '\n' then add it - if len(data) > 0 && data[len(data)-1] != '\n' { - data = append(data, '\n') - } - - return plugin.ApplyRangeRead(data, offset, size) -} - -func (fs *serverInfoFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - return 0, fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) Create(path string) error { - return fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) Mkdir(path string, perm uint32) error { - return fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) Remove(path string) error { - return fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) RemoveAll(path string) error { - return fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - if path != "/" { - return nil, fmt.Errorf("not a directory: %s", path) - } - - now := time.Now() - readme := fs.plugin.GetReadme() - - // Generate content for each file to get accurate sizes - serverInfoData, _ := fs.Read(fileServerInfo, 0, -1) - uptimeData, _ := fs.Read(fileUptime, 0, -1) - versionData, _ := fs.Read(fileVersion, 0, -1) - statsData, _ := fs.Read(fileStats, 0, -1) - trafficData, _ := fs.Read(fileTraffic, 0, -1) - - return []filesystem.FileInfo{ - { - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: "doc"}, - }, - { - Name: "server_info", - Size: int64(len(serverInfoData)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: "info"}, - }, - { - Name: "uptime", - Size: int64(len(uptimeData)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: "info"}, - }, - { - Name: "version", - Size: int64(len(versionData)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: "info"}, - }, - { - Name: "stats", - Size: int64(len(statsData)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: "info"}, - }, - { - Name: "traffic", - Size: int64(len(trafficData)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: "traffic"}, - }, - }, nil -} - -func (fs *serverInfoFS) Stat(path string) (*filesystem.FileInfo, error) { - if !fs.isValidPath(path) { - return nil, filesystem.NewNotFoundError("stat", path) - } - - now := time.Now() - - if path == "/" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0555, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: "serverinfofs"}, - }, nil - } - - // For files, read content to get size - data, err := fs.Read(path, 0, -1) - if err != nil && err != io.EOF { - return nil, err - } - - fileType := "info" - if path == fileReadme { - fileType = "doc" - } - - return &filesystem.FileInfo{ - Name: path[1:], // Remove leading slash - Size: int64(len(data)), - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: "serverinfofs", Type: fileType}, - }, nil -} - -func (fs *serverInfoFS) Rename(oldPath, newPath string) error { - return fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - -func (fs *serverInfoFS) Open(path string) (io.ReadCloser, error) { - data, err := fs.Read(path, 0, -1) - if err != nil { - return nil, err - } - return io.NopCloser(bytes.NewReader(data)), nil -} - -func (fs *serverInfoFS) OpenWrite(path string) (io.WriteCloser, error) { - return nil, fmt.Errorf("operation not permitted: serverinfofs is read-only") -} - diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/README.md b/third_party/agfs/agfs-server/pkg/plugins/sqlfs/README.md deleted file mode 100644 index e7d53f613..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/README.md +++ /dev/null @@ -1,189 +0,0 @@ -SQLFS Plugin - Database-backed File System - -This plugin provides a persistent file system backed by database storage. - -FEATURES: - - Persistent storage (survives server restarts) - - Full POSIX-like file system operations - - Multiple database backends (SQLite, TiDB) - - Efficient database-backed storage - - ACID transactions - - Supports files and directories - - Maximum file size: 5MB per file - -DYNAMIC MOUNTING WITH AGFS SHELL: - - Interactive shell - SQLite: - agfs:/> mount sqlfs /db backend=sqlite db_path=/tmp/mydata.db - agfs:/> mount sqlfs /persistent backend=sqlite db_path=./storage.db - agfs:/> mount sqlfs /cache backend=sqlite db_path=/tmp/cache.db cache_enabled=true cache_max_size=2000 - - Interactive shell - TiDB: - agfs:/> mount sqlfs /tidb backend=tidb dsn="user:pass@tcp(localhost:4000)/database" - agfs:/> mount sqlfs /cloud backend=tidb user=root password=mypass host=tidb-server.com port=4000 database=agfs_data enable_tls=true - - Direct command - SQLite: - uv run agfs mount sqlfs /db backend=sqlite db_path=/tmp/test.db - - Direct command - TiDB: - uv run agfs mount sqlfs /tidb backend=tidb dsn="user:pass@tcp(host:4000)/db" - -CONFIGURATION PARAMETERS: - - Required (SQLite): - - backend: "sqlite" or "sqlite3" - - db_path: Path to SQLite database file (created if doesn't exist) - - Required (TiDB) - Option 1 (DSN): - - backend: "tidb" - - dsn: Full database connection string (e.g., "user:pass@tcp(host:4000)/db") - - Required (TiDB) - Option 2 (Individual parameters): - - backend: "tidb" - - user: Database username - - password: Database password - - host: Database host - - port: Database port (typically 4000) - - database: Database name - - Optional (All backends): - - cache_enabled: Enable directory listing cache (default: true) - - cache_max_size: Maximum cached entries (default: 1000) - - cache_ttl_seconds: Cache TTL in seconds (default: 5) - - enable_tls: Enable TLS for TiDB (default: false) - - tls_server_name: TLS server name for TiDB - - Examples: - # Multiple databases - agfs:/> mount sqlfs /local backend=sqlite db_path=local.db - agfs:/> mount sqlfs /shared backend=tidb dsn="user:pass@tcp(shared-db:4000)/agfs" - - # With custom cache settings - agfs:/> mount sqlfs /fast backend=sqlite db_path=fast.db cache_enabled=true cache_max_size=5000 cache_ttl_seconds=10 - -STATIC CONFIGURATION (config.yaml): - - Alternative to dynamic mounting - configure in server config file: - - SQLite Backend (Local Testing): - [plugins.sqlfs] - enabled = true - path = "/sqlfs" - - [plugins.sqlfs.config] - backend = "sqlite" # or "sqlite3" - db_path = "sqlfs.db" - - # Optional cache settings (enabled by default) - cache_enabled = true # Enable/disable directory listing cache - cache_max_size = 1000 # Maximum number of cached entries (default: 1000) - cache_ttl_seconds = 5 # Cache entry TTL in seconds (default: 5) - - TiDB Backend (Production): - [plugins.sqlfs] - enabled = true - path = "/sqlfs" - - [plugins.sqlfs.config] - backend = "tidb" - - # For TiDB Cloud (TLS required): - user = "3YdGXuXNdAEmP1f.root" - password = "your_password" - host = "gateway01.us-west-2.prod.aws.tidbcloud.com" - port = "4000" - database = "baas" - enable_tls = true - tls_server_name = "gateway01.us-west-2.prod.aws.tidbcloud.com" - - # Or use DSN with TLS: - # dsn = "user:password@tcp(host:4000)/database?charset=utf8mb4&parseTime=True&tls=tidb" - -USAGE: - - Create a directory: - agfs mkdir /sqlfs/mydir - - Create a file: - agfs write /sqlfs/mydir/file.txt "Hello, World!" - - Read a file: - agfs cat /sqlfs/mydir/file.txt - - List directory: - agfs ls /sqlfs/mydir - - Get file info: - agfs stat /sqlfs/mydir/file.txt - - Rename file: - agfs mv /sqlfs/mydir/file.txt /sqlfs/mydir/newfile.txt - - Change permissions: - agfs chmod 755 /sqlfs/mydir/file.txt - - Remove file: - agfs rm /sqlfs/mydir/file.txt - - Remove directory (must be empty): - agfs rm /sqlfs/mydir - - Remove directory recursively: - agfs rm -r /sqlfs/mydir - -EXAMPLES: - - # Create directory structure - agfs:/> mkdir /sqlfs/data - agfs:/> mkdir /sqlfs/data/logs - - # Write files - agfs:/> echo "Configuration data" > /sqlfs/data/config.txt - agfs:/> echo "Log entry" > /sqlfs/data/logs/app.log - - # Read files - agfs:/> cat /sqlfs/data/config.txt - Configuration data - - # List directory - agfs:/> ls /sqlfs/data - config.txt - logs/ - -ADVANTAGES: - - Data persists across server restarts - - Efficient storage with database compression - - Transaction safety (ACID properties) - - Query capabilities (can be extended) - - Backup friendly (single database file) - - Fast directory listing with LRU cache (improves shell completion) - -USE CASES: - - Persistent configuration storage - - Log file storage - - Document management - - Application data storage - - Backup and archival - - Development and testing with persistent data - -TECHNICAL DETAILS: - - Database: SQLite 3 / TiDB (MySQL-compatible) - - Journal mode: WAL (Write-Ahead Logging) for SQLite - - Schema: Single table with path, metadata, and blob data - - Concurrent reads supported - - Write serialization via mutex - - Path normalization and validation - - LRU cache for directory listings (configurable TTL and size) - - Automatic cache invalidation on modifications - -LIMITATIONS: - - Maximum file size: 5MB per file - - Not suitable for large files (use MemFS or StreamFS for larger data) - - Write operations are serialized - - No file locking mechanism - - No sparse file support - - No streaming support (use StreamFS for real-time streaming) - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/backend.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs/backend.go deleted file mode 100644 index 00805f0a1..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/backend.go +++ /dev/null @@ -1,277 +0,0 @@ -package sqlfs - -import ( - "crypto/tls" - "database/sql" - "fmt" - "regexp" - "strings" - - "github.com/go-sql-driver/mysql" - _ "github.com/go-sql-driver/mysql" // MySQL/TiDB driver - log "github.com/sirupsen/logrus" -) - -// DBBackend defines the interface for different database backends -type DBBackend interface { - // Open opens a connection to the database - Open(config map[string]interface{}) (*sql.DB, error) - - // GetDriverName returns the driver name (e.g., "sqlite3", "mysql") - GetDriverName() string - - // GetInitSQL returns the SQL statements to initialize the schema - GetInitSQL() []string - - // SupportsTxIsolation returns whether the backend supports transaction isolation levels - SupportsTxIsolation() bool - - // GetOptimizationSQL returns SQL statements for optimization (e.g., PRAGMA for SQLite) - GetOptimizationSQL() []string -} - -// SQLiteBackend implements DBBackend for SQLite -type SQLiteBackend struct{} - -func NewSQLiteBackend() *SQLiteBackend { - return &SQLiteBackend{} -} - -func (b *SQLiteBackend) GetDriverName() string { - return "sqlite3" -} - -func (b *SQLiteBackend) Open(config map[string]interface{}) (*sql.DB, error) { - dbPath := "sqlfs.db" // default - if path, ok := config["db_path"].(string); ok && path != "" { - dbPath = path - } - - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, fmt.Errorf("failed to open SQLite database: %w", err) - } - - return db, nil -} - -func (b *SQLiteBackend) GetInitSQL() []string { - return []string{ - `CREATE TABLE IF NOT EXISTS files ( - path TEXT PRIMARY KEY, - is_dir INTEGER NOT NULL, - mode INTEGER NOT NULL, - size INTEGER NOT NULL, - mod_time INTEGER NOT NULL, - data BLOB - )`, - `CREATE INDEX IF NOT EXISTS idx_parent ON files(path)`, - } -} - -func (b *SQLiteBackend) GetOptimizationSQL() []string { - return []string{ - "PRAGMA journal_mode=WAL", - "PRAGMA synchronous=NORMAL", - "PRAGMA cache_size=-64000", // 64MB cache - } -} - -func (b *SQLiteBackend) SupportsTxIsolation() bool { - return false -} - -// TiDBBackend implements DBBackend for TiDB -type TiDBBackend struct{} - -func NewTiDBBackend() *TiDBBackend { - return &TiDBBackend{} -} - -func (b *TiDBBackend) GetDriverName() string { - return "mysql" -} - -func (b *TiDBBackend) Open(config map[string]interface{}) (*sql.DB, error) { - // Check if DSN contains tls parameter - dsnStr := getStringConfig(config, "dsn", "") - dsnHasTLS := strings.Contains(dsnStr, "tls=") - - // Register TLS configuration if needed - enableTLS := getBoolConfig(config, "enable_tls", false) || dsnHasTLS - tlsConfigName := "tidb" - - if enableTLS { - // Get TLS configuration - serverName := getStringConfig(config, "tls_server_name", "") - - // If no explicit server name, try to extract from DSN or host - if serverName == "" { - if dsnStr != "" { - // Extract host from DSN: user:pass@tcp(host:port)/db - re := regexp.MustCompile(`@tcp\(([^:]+):\d+\)`) - if matches := re.FindStringSubmatch(dsnStr); len(matches) > 1 { - serverName = matches[1] - } - } else { - // Use host config - serverName = getStringConfig(config, "host", "") - } - } - - skipVerify := getBoolConfig(config, "tls_skip_verify", false) - - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - if serverName != "" { - tlsConfig.ServerName = serverName - } - - if skipVerify { - tlsConfig.InsecureSkipVerify = true - log.Warn("[sqlfs] TLS certificate verification is disabled (insecure)") - } - - // Register TLS config with MySQL driver - if err := mysql.RegisterTLSConfig(tlsConfigName, tlsConfig); err != nil { - log.Warnf("[sqlfs] Failed to register TLS config (may already exist): %v", err) - } - } - - // Parse TiDB connection string - // Format: user:password@tcp(host:port)/database - dsn := "" - - if dsnStr, ok := config["dsn"].(string); ok && dsnStr != "" { - dsn = dsnStr - } else { - // Build DSN from individual components - user := getStringConfig(config, "user", "root") - password := getStringConfig(config, "password", "") - host := getStringConfig(config, "host", "127.0.0.1") - port := getStringConfig(config, "port", "4000") - database := getStringConfig(config, "database", "sqlfs") - - // Build base DSN - if password != "" { - dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, password, host, port, database) - } else { - dsn = fmt.Sprintf("%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, host, port, database) - } - - // Add TLS parameter if enabled - if enableTLS { - dsn += fmt.Sprintf("&tls=%s", tlsConfigName) - } - } - - log.Infof("[sqlfs] Connecting to TiDB (TLS: %v)", enableTLS) - - // Extract database name to create it if needed - dbName := extractDatabaseName(dsn, getStringConfig(config, "database", "")) - - // First, try to connect without database to create it if needed - if dbName != "" { - dsnWithoutDB := removeDatabaseFromDSN(dsn) - if dsnWithoutDB != dsn { - tempDB, err := sql.Open("mysql", dsnWithoutDB) - defer tempDB.Close() - if err == nil { - // Try to create database if it doesn't exist - _, err = tempDB.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", dbName)) - if err != nil { - log.Errorf("[sqlfs] Failed to create database '%s': %v", dbName, err) - return nil, err - } - } - } - } - - db, err := sql.Open("mysql", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open TiDB database: %w", err) - } - - // Set connection pool parameters - // TODO: make it configurable - db.SetMaxOpenConns(100) - db.SetMaxIdleConns(10) - - return db, nil -} - -// extractDatabaseName extracts database name from DSN or config -func extractDatabaseName(dsn string, configDB string) string { - if dsn != "" { - // Extract from DSN: ...)/database?... - re := regexp.MustCompile(`\)/([^?]+)`) - if matches := re.FindStringSubmatch(dsn); len(matches) > 1 { - return matches[1] - } - } - return configDB -} - -// removeDatabaseFromDSN removes database name from DSN -func removeDatabaseFromDSN(dsn string) string { - // Replace )/database? with )/? - re := regexp.MustCompile(`\)/[^?]+(\?|$)`) - return re.ReplaceAllString(dsn, ")/$1") -} - -func (b *TiDBBackend) GetInitSQL() []string { - return []string{ - `CREATE TABLE IF NOT EXISTS files ( - path VARCHAR(3072) PRIMARY KEY, - is_dir TINYINT NOT NULL, - mode INT UNSIGNED NOT NULL, - size BIGINT NOT NULL, - mod_time BIGINT NOT NULL, - data LONGBLOB, - INDEX idx_parent (path(200)) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, - } -} - -func (b *TiDBBackend) GetOptimizationSQL() []string { - // TiDB doesn't need special optimization SQL - return []string{} -} - -func (b *TiDBBackend) SupportsTxIsolation() bool { - return true -} - -// getStringConfig retrieves a string value from config map with default -func getStringConfig(config map[string]interface{}, key, defaultValue string) string { - if val, ok := config[key].(string); ok && val != "" { - return val - } - return defaultValue -} - -// getBoolConfig retrieves a boolean value from config map with default -func getBoolConfig(config map[string]interface{}, key string, defaultValue bool) bool { - if val, ok := config[key].(bool); ok { - return val - } - return defaultValue -} - -// CreateBackend creates the appropriate backend based on configuration -func CreateBackend(config map[string]interface{}) (DBBackend, error) { - backendType := getStringConfig(config, "backend", "sqlite") - - switch backendType { - case "sqlite", "sqlite3": - return NewSQLiteBackend(), nil - case "tidb", "mysql": - return NewTiDBBackend(), nil - default: - return nil, fmt.Errorf("unsupported database backend: %s", backendType) - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/cache.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs/cache.go deleted file mode 100644 index 9504ea043..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/cache.go +++ /dev/null @@ -1,211 +0,0 @@ -package sqlfs - -import ( - "container/list" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" -) - -// CacheEntry represents a cached directory listing -type CacheEntry struct { - Files []filesystem.FileInfo - ModTime time.Time -} - -// ListDirCache implements an LRU cache for directory listings -type ListDirCache struct { - mu sync.RWMutex - cache map[string]*list.Element // path -> list element - lruList *list.List // LRU list of cache entries - maxSize int // maximum number of entries - ttl time.Duration // time-to-live for cache entries - enabled bool // whether cache is enabled - hitCount uint64 // cache hit counter - missCount uint64 // cache miss counter -} - -// cacheItem is the value stored in the LRU list -type cacheItem struct { - path string - entry *CacheEntry -} - -// NewListDirCache creates a new directory listing cache -func NewListDirCache(maxSize int, ttl time.Duration, enabled bool) *ListDirCache { - if maxSize <= 0 { - maxSize = 1000 // default max size - } - if ttl <= 0 { - ttl = 5 * time.Second // default TTL - } - - return &ListDirCache{ - cache: make(map[string]*list.Element), - lruList: list.New(), - maxSize: maxSize, - ttl: ttl, - enabled: enabled, - } -} - -// Get retrieves a cached directory listing -func (c *ListDirCache) Get(path string) ([]filesystem.FileInfo, bool) { - if !c.enabled { - return nil, false - } - - c.mu.Lock() - defer c.mu.Unlock() - - elem, ok := c.cache[path] - if !ok { - c.missCount++ - return nil, false - } - - item := elem.Value.(*cacheItem) - - // Check if entry is expired - if time.Since(item.entry.ModTime) > c.ttl { - c.lruList.Remove(elem) - delete(c.cache, path) - c.missCount++ - return nil, false - } - - // Move to front (most recently used) - c.lruList.MoveToFront(elem) - c.hitCount++ - - // Return a copy to prevent external modification - files := make([]filesystem.FileInfo, len(item.entry.Files)) - copy(files, item.entry.Files) - return files, true -} - -// Put adds a directory listing to the cache -func (c *ListDirCache) Put(path string, files []filesystem.FileInfo) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - // Check if entry already exists - if elem, ok := c.cache[path]; ok { - // Update existing entry - item := elem.Value.(*cacheItem) - item.entry.Files = files - item.entry.ModTime = time.Now() - c.lruList.MoveToFront(elem) - return - } - - // Create new entry - entry := &CacheEntry{ - Files: files, - ModTime: time.Now(), - } - - item := &cacheItem{ - path: path, - entry: entry, - } - - elem := c.lruList.PushFront(item) - c.cache[path] = elem - - // Evict oldest entry if cache is full - if c.lruList.Len() > c.maxSize { - oldest := c.lruList.Back() - if oldest != nil { - c.lruList.Remove(oldest) - oldestItem := oldest.Value.(*cacheItem) - delete(c.cache, oldestItem.path) - } - } -} - -// Invalidate removes a specific path from the cache -func (c *ListDirCache) Invalidate(path string) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - if elem, ok := c.cache[path]; ok { - c.lruList.Remove(elem) - delete(c.cache, path) - } -} - -// InvalidatePrefix removes all paths with the given prefix from cache -// This is useful when a directory or its parent is modified -func (c *ListDirCache) InvalidatePrefix(prefix string) { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - // Collect paths to invalidate - toDelete := make([]string, 0) - for path := range c.cache { - if path == prefix || isDescendant(path, prefix) { - toDelete = append(toDelete, path) - } - } - - // Remove from cache - for _, path := range toDelete { - if elem, ok := c.cache[path]; ok { - c.lruList.Remove(elem) - delete(c.cache, path) - } - } -} - -// InvalidateParent invalidates the parent directory of a given path -func (c *ListDirCache) InvalidateParent(path string) { - parent := getParentPath(path) - c.Invalidate(parent) -} - -// Clear removes all entries from the cache -func (c *ListDirCache) Clear() { - if !c.enabled { - return - } - - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string]*list.Element) - c.lruList = list.New() -} - -// isDescendant checks if path is a descendant of parent -func isDescendant(path, parent string) bool { - // A path is not a descendant of itself - if path == parent { - return false - } - - // Special case for root: everything is a descendant except root itself - if parent == "/" { - return path != "/" - } - - // Check if path starts with parent + "/" - if len(path) <= len(parent) { - return false - } - - return path[:len(parent)] == parent && path[len(parent)] == '/' -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/sqlfs.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs/sqlfs.go deleted file mode 100644 index 652e4a34f..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs/sqlfs.go +++ /dev/null @@ -1,980 +0,0 @@ -package sqlfs - -import ( - "database/sql" - "fmt" - "io" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - _ "github.com/mattn/go-sqlite3" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "sqlfs" - MaxFileSize = 5 * 1024 * 1024 // 5MB maximum file size - MaxFileSizeMB = 5 -) - -// SQLFSPlugin provides a database-backed file system -type SQLFSPlugin struct { - fs *SQLFS - backend DBBackend - config map[string]interface{} -} - -// NewSQLFSPlugin creates a new SQLFS plugin -func NewSQLFSPlugin() *SQLFSPlugin { - return &SQLFSPlugin{} -} - -func (p *SQLFSPlugin) Name() string { - return PluginName -} - -func (p *SQLFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"backend", "db_path", "dsn", "user", "password", "host", "port", "database", - "cache_enabled", "cache_max_size", "cache_ttl_seconds", "mount_path"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate backend type - backendType := config.GetStringConfig(cfg, "backend", "sqlite") - validBackends := map[string]bool{ - "sqlite": true, - "sqlite3": true, - "tidb": true, - "mysql": true, - } - if !validBackends[backendType] { - return fmt.Errorf("unsupported database backend: %s (valid options: sqlite, sqlite3, tidb, mysql)", backendType) - } - - // Validate optional string parameters - for _, key := range []string{"db_path", "dsn", "user", "password", "host", "database"} { - if err := config.ValidateStringType(cfg, key); err != nil { - return err - } - } - - // Validate optional integer parameters - for _, key := range []string{"port", "cache_max_size", "cache_ttl_seconds"} { - if err := config.ValidateIntType(cfg, key); err != nil { - return err - } - } - - // Validate cache_enabled (optional boolean) - if err := config.ValidateBoolType(cfg, "cache_enabled"); err != nil { - return err - } - - return nil -} - -func (p *SQLFSPlugin) Initialize(config map[string]interface{}) error { - p.config = config - - // Create appropriate backend - backend, err := CreateBackend(config) - if err != nil { - return fmt.Errorf("failed to create backend: %w", err) - } - p.backend = backend - - // Create SQLFS instance with the backend - fs, err := NewSQLFS(backend, config) - if err != nil { - return fmt.Errorf("failed to initialize sqlfs: %w", err) - } - p.fs = fs - - backendType := "sqlite" - if bt, ok := config["backend"].(string); ok && bt != "" { - backendType = bt - } - log.Infof("[sqlfs] Initialized with backend: %s", backendType) - return nil -} - -func (p *SQLFSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *SQLFSPlugin) GetReadme() string { - return getReadme() -} - -func (p *SQLFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "backend", - Type: "string", - Required: false, - Default: "sqlite", - Description: "Database backend (sqlite, sqlite3, tidb)", - }, - { - Name: "db_path", - Type: "string", - Required: false, - Default: "", - Description: "Database file path (for SQLite)", - }, - { - Name: "dsn", - Type: "string", - Required: false, - Default: "", - Description: "Database connection string (DSN)", - }, - { - Name: "user", - Type: "string", - Required: false, - Default: "", - Description: "Database username", - }, - { - Name: "password", - Type: "string", - Required: false, - Default: "", - Description: "Database password", - }, - { - Name: "host", - Type: "string", - Required: false, - Default: "", - Description: "Database host", - }, - { - Name: "port", - Type: "int", - Required: false, - Default: "", - Description: "Database port", - }, - { - Name: "database", - Type: "string", - Required: false, - Default: "", - Description: "Database name", - }, - { - Name: "cache_enabled", - Type: "bool", - Required: false, - Default: "false", - Description: "Enable result caching", - }, - { - Name: "cache_max_size", - Type: "int", - Required: false, - Default: "1000", - Description: "Maximum cache size (number of entries)", - }, - { - Name: "cache_ttl_seconds", - Type: "int", - Required: false, - Default: "300", - Description: "Cache TTL in seconds", - }, - } -} - -func (p *SQLFSPlugin) Shutdown() error { - if p.fs != nil { - return p.fs.Close() - } - return nil -} - -// SQLFS implements FileSystem interface using a database backend -type SQLFS struct { - db *sql.DB - backend DBBackend - mu sync.RWMutex - pluginName string - listCache *ListDirCache // cache for directory listings -} - -// FileEntry represents a file or directory in the database -type FileEntry struct { - Path string - IsDir bool - Mode uint32 - Size int64 - ModTime time.Time - Data []byte -} - -// NewSQLFS creates a new database-backed file system -func NewSQLFS(backend DBBackend, config map[string]interface{}) (*SQLFS, error) { - db, err := backend.Open(config) - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Apply backend-specific optimizations - for _, sql := range backend.GetOptimizationSQL() { - if _, err := db.Exec(sql); err != nil { - db.Close() - return nil, fmt.Errorf("failed to apply optimization: %w", err) - } - } - - // Parse cache configuration - cacheEnabled := true // enabled by default - cacheMaxSize := 1000 // default 1000 entries - cacheTTLSeconds := 5 // default 5 seconds - - if val, ok := config["cache_enabled"].(bool); ok { - cacheEnabled = val - } - if val, ok := config["cache_max_size"].(int); ok && val > 0 { - cacheMaxSize = val - } - if val, ok := config["cache_ttl_seconds"].(int); ok && val > 0 { - cacheTTLSeconds = val - } - - fs := &SQLFS{ - db: db, - backend: backend, - pluginName: PluginName, - listCache: NewListDirCache(cacheMaxSize, time.Duration(cacheTTLSeconds)*time.Second, cacheEnabled), - } - - // Initialize database schema - if err := fs.initSchema(); err != nil { - db.Close() - return nil, fmt.Errorf("failed to initialize schema: %w", err) - } - - // Ensure root directory exists - if err := fs.ensureRootExists(); err != nil { - db.Close() - return nil, fmt.Errorf("failed to create root directory: %w", err) - } - - return fs, nil -} - -// initSchema creates the database schema -func (fs *SQLFS) initSchema() error { - for _, sql := range fs.backend.GetInitSQL() { - if _, err := fs.db.Exec(sql); err != nil { - return fmt.Errorf("failed to execute init SQL: %w", err) - } - } - return nil -} - -// ensureRootExists ensures the root directory exists -func (fs *SQLFS) ensureRootExists() error { - fs.mu.Lock() - defer fs.mu.Unlock() - - var exists int - err := fs.db.QueryRow("SELECT COUNT(*) FROM files WHERE path = '/'").Scan(&exists) - if err != nil { - return err - } - - if exists == 0 { - _, err = fs.db.Exec( - "INSERT INTO files (path, is_dir, mode, size, mod_time, data) VALUES (?, ?, ?, ?, ?, ?)", - "/", 1, 0755, 0, time.Now().Unix(), nil, - ) - return err - } - - return nil -} - -// Close closes the database connection -func (fs *SQLFS) Close() error { - fs.mu.Lock() - defer fs.mu.Unlock() - - if fs.db != nil { - return fs.db.Close() - } - return nil -} - -// getParentPath returns the parent directory path -func getParentPath(path string) string { - if path == "/" { - return "/" - } - parent := filepath.Dir(path) - if parent == "." { - return "/" - } - return parent -} - -func (fs *SQLFS) Create(path string) error { - path = filesystem.NormalizePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if parent directory exists - parent := getParentPath(path) - if parent != "/" { - var isDir int - err := fs.db.QueryRow("SELECT is_dir FROM files WHERE path = ?", parent).Scan(&isDir) - if err == sql.ErrNoRows { - return filesystem.NewNotFoundError("create", parent) - } else if err != nil { - return err - } - if isDir == 0 { - return filesystem.NewNotDirectoryError(parent) - } - } - - // Check if file already exists - var exists int - err := fs.db.QueryRow("SELECT COUNT(*) FROM files WHERE path = ?", path).Scan(&exists) - if err != nil { - return err - } - if exists > 0 { - return filesystem.NewAlreadyExistsError("file", path) - } - - // Create empty file - _, err = fs.db.Exec( - "INSERT INTO files (path, is_dir, mode, size, mod_time, data) VALUES (?, ?, ?, ?, ?, ?)", - path, 0, 0644, 0, time.Now().Unix(), []byte{}, - ) - - // Invalidate parent directory cache - if err == nil { - fs.listCache.InvalidateParent(path) - } - - return err -} - -func (fs *SQLFS) Mkdir(path string, perm uint32) error { - path = filesystem.NormalizePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if parent directory exists - parent := getParentPath(path) - if parent != "/" { - var isDir int - err := fs.db.QueryRow("SELECT is_dir FROM files WHERE path = ?", parent).Scan(&isDir) - if err == sql.ErrNoRows { - return filesystem.NewNotFoundError("mkdir", parent) - } else if err != nil { - return err - } - if isDir == 0 { - return filesystem.NewNotDirectoryError(parent) - } - } - - // Check if directory already exists - var exists int - err := fs.db.QueryRow("SELECT COUNT(*) FROM files WHERE path = ?", path).Scan(&exists) - if err != nil { - return err - } - if exists > 0 { - return filesystem.NewAlreadyExistsError("directory", path) - } - - // Create directory - if perm == 0 { - perm = 0755 - } - _, err = fs.db.Exec( - "INSERT INTO files (path, is_dir, mode, size, mod_time, data) VALUES (?, ?, ?, ?, ?, ?)", - path, 1, perm, 0, time.Now().Unix(), nil, - ) - - // Invalidate parent directory cache - if err == nil { - fs.listCache.InvalidateParent(path) - } - - return err -} - -func (fs *SQLFS) Remove(path string) error { - path = filesystem.NormalizePath(path) - - if path == "/" { - return fmt.Errorf("cannot remove root directory") - } - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if file exists and is not a directory - var isDir int - err := fs.db.QueryRow("SELECT is_dir FROM files WHERE path = ?", path).Scan(&isDir) - if err == sql.ErrNoRows { - return filesystem.NewNotFoundError("remove", path) - } else if err != nil { - return err - } - - if isDir == 1 { - // Check if directory is empty - var count int - err = fs.db.QueryRow("SELECT COUNT(*) FROM files WHERE path LIKE ? AND path != ?", path+"/%", path).Scan(&count) - if err != nil { - return err - } - if count > 0 { - return fmt.Errorf("directory not empty: %s", path) - } - } - - // Delete file - _, err = fs.db.Exec("DELETE FROM files WHERE path = ?", path) - - // Invalidate parent directory cache and the path itself if it's a directory - if err == nil { - fs.listCache.InvalidateParent(path) - fs.listCache.Invalidate(path) - } - - return err -} - -func (fs *SQLFS) RemoveAll(path string) error { - path = filesystem.NormalizePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Use batched deletion to avoid long-running transactions and locks - const batchSize = 1000 - - // If path is root, remove all children but not the root itself - if path == "/" { - for { - result, err := fs.db.Exec("DELETE FROM files WHERE path != '/' LIMIT ?", batchSize) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - // If no rows were affected, we're done - if affected == 0 { - break - } - // If fewer rows than batch size were deleted, we're done - if affected < int64(batchSize) { - break - } - } - // Invalidate entire cache - fs.listCache.InvalidatePrefix("/") - return nil - } - - // Delete file and all children in batches - for { - result, err := fs.db.Exec("DELETE FROM files WHERE (path = ? OR path LIKE ?) LIMIT ?", path, path+"/%", batchSize) - if err != nil { - return err - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - // If no rows were affected, we're done - if affected == 0 { - break - } - // If fewer rows than batch size were deleted, we're done - if affected < int64(batchSize) { - break - } - } - - // Invalidate cache for the path and all descendants - fs.listCache.InvalidateParent(path) - fs.listCache.InvalidatePrefix(path) - - return nil -} - -func (fs *SQLFS) Read(path string, offset int64, size int64) ([]byte, error) { - path = filesystem.NormalizePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - var isDir int - var data []byte - err := fs.db.QueryRow("SELECT is_dir, data FROM files WHERE path = ?", path).Scan(&isDir, &data) - if err == sql.ErrNoRows { - return nil, filesystem.NewNotFoundError("read", path) - } else if err != nil { - return nil, err - } - - if isDir == 1 { - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - } - - // Apply offset and size - dataLen := int64(len(data)) - if offset < 0 { - offset = 0 - } - if offset >= dataLen { - return []byte{}, io.EOF - } - - end := dataLen - if size >= 0 { - end = offset + size - if end > dataLen { - end = dataLen - } - } - - result := data[offset:end] - if end >= dataLen { - return result, io.EOF - } - return result, nil -} - -func (fs *SQLFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - path = filesystem.NormalizePath(path) - - // Check file size limit - if len(data) > MaxFileSize { - return 0, fmt.Errorf("file size exceeds maximum limit of %dMB (got %d bytes)", MaxFileSizeMB, len(data)) - } - - // SQLFS doesn't support offset writes - it's more like an object store - if offset >= 0 && offset != 0 { - return 0, fmt.Errorf("SQLFS does not support offset writes") - } - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if file exists - var exists int - var isDir int - err := fs.db.QueryRow("SELECT COUNT(*), COALESCE(MAX(is_dir), 0) FROM files WHERE path = ?", path).Scan(&exists, &isDir) - if err != nil { - return 0, err - } - - if exists > 0 && isDir == 1 { - return 0, filesystem.NewInvalidArgumentError("path", path, "is a directory") - } - - if exists == 0 { - // File doesn't exist, create it - parent := getParentPath(path) - if parent != "/" { - var parentIsDir int - err := fs.db.QueryRow("SELECT is_dir FROM files WHERE path = ?", parent).Scan(&parentIsDir) - if err == sql.ErrNoRows { - return 0, filesystem.NewNotFoundError("write", parent) - } else if err != nil { - return 0, err - } - if parentIsDir == 0 { - return 0, filesystem.NewNotDirectoryError(parent) - } - } - - _, err = fs.db.Exec( - "INSERT INTO files (path, is_dir, mode, size, mod_time, data) VALUES (?, ?, ?, ?, ?, ?)", - path, 0, 0644, len(data), time.Now().Unix(), data, - ) - - // Invalidate parent directory cache on new file creation - if err == nil { - fs.listCache.InvalidateParent(path) - } - } else { - // Update existing file - _, err = fs.db.Exec( - "UPDATE files SET data = ?, size = ?, mod_time = ? WHERE path = ?", - data, len(data), time.Now().Unix(), path, - ) - // Note: no need to invalidate parent cache on update, only on create/delete - } - - if err != nil { - return 0, err - } - - return int64(len(data)), nil -} - -func (fs *SQLFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - path = filesystem.NormalizePath(path) - - // Try to get from cache first - if files, found := fs.listCache.Get(path); found { - return files, nil - } - - fs.mu.RLock() - defer fs.mu.RUnlock() - - // Check if directory exists - var isDir int - err := fs.db.QueryRow("SELECT is_dir FROM files WHERE path = ?", path).Scan(&isDir) - if err == sql.ErrNoRows { - return nil, filesystem.NewNotFoundError("readdir", path) - } else if err != nil { - return nil, err - } - - if isDir == 0 { - return nil, filesystem.NewNotDirectoryError(path) - } - - // Query children - pattern := path - if path != "/" { - pattern = path + "/" - } - - rows, err := fs.db.Query( - "SELECT path, is_dir, mode, size, mod_time FROM files WHERE path LIKE ? AND path != ? AND path NOT LIKE ?", - pattern+"%", path, pattern+"%/%", - ) - if err != nil { - return nil, err - } - defer rows.Close() - - var files []filesystem.FileInfo - for rows.Next() { - var filePath string - var isDir int - var mode uint32 - var size int64 - var modTime int64 - - if err := rows.Scan(&filePath, &isDir, &mode, &size, &modTime); err != nil { - return nil, err - } - - name := filepath.Base(filePath) - files = append(files, filesystem.FileInfo{ - Name: name, - Size: size, - Mode: mode, - ModTime: time.Unix(modTime, 0), - IsDir: isDir == 1, - Meta: filesystem.MetaData{ - Name: PluginName, - }, - }) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - // Cache the result - fs.listCache.Put(path, files) - - return files, nil -} - -func (fs *SQLFS) Stat(path string) (*filesystem.FileInfo, error) { - path = filesystem.NormalizePath(path) - - fs.mu.RLock() - defer fs.mu.RUnlock() - - var isDir int - var mode uint32 - var size int64 - var modTime int64 - - err := fs.db.QueryRow( - "SELECT is_dir, mode, size, mod_time FROM files WHERE path = ?", - path, - ).Scan(&isDir, &mode, &size, &modTime) - - if err == sql.ErrNoRows { - return nil, filesystem.NewNotFoundError("stat", path) - } else if err != nil { - return nil, err - } - - name := filepath.Base(path) - if path == "/" { - name = "/" - } - - return &filesystem.FileInfo{ - Name: name, - Size: size, - Mode: mode, - ModTime: time.Unix(modTime, 0), - IsDir: isDir == 1, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: fs.backend.GetDriverName(), - }, - }, nil -} - -func (fs *SQLFS) Rename(oldPath, newPath string) error { - oldPath = filesystem.NormalizePath(oldPath) - newPath = filesystem.NormalizePath(newPath) - - if oldPath == "/" || newPath == "/" { - return fmt.Errorf("cannot rename root directory") - } - - fs.mu.Lock() - defer fs.mu.Unlock() - - // Check if old path exists - var exists int - err := fs.db.QueryRow("SELECT COUNT(*) FROM files WHERE path = ?", oldPath).Scan(&exists) - if err != nil { - return err - } - if exists == 0 { - return filesystem.NewNotFoundError("rename", oldPath) - } - - // Check if new path already exists - err = fs.db.QueryRow("SELECT COUNT(*) FROM files WHERE path = ?", newPath).Scan(&exists) - if err != nil { - return err - } - if exists > 0 { - return filesystem.NewAlreadyExistsError("file", newPath) - } - - // Rename file/directory - _, err = fs.db.Exec("UPDATE files SET path = ? WHERE path = ?", newPath, oldPath) - if err != nil { - return err - } - - // If it's a directory, rename all children - _, err = fs.db.Exec( - "UPDATE files SET path = ? || SUBSTR(path, ?) WHERE path LIKE ?", - newPath, len(oldPath)+1, oldPath+"/%", - ) - - // Invalidate cache for old and new parent directories - if err == nil { - fs.listCache.InvalidateParent(oldPath) - fs.listCache.InvalidateParent(newPath) - fs.listCache.Invalidate(oldPath) - fs.listCache.InvalidatePrefix(oldPath) - } - - return err -} - -func (fs *SQLFS) Chmod(path string, mode uint32) error { - path = filesystem.NormalizePath(path) - - fs.mu.Lock() - defer fs.mu.Unlock() - - result, err := fs.db.Exec("UPDATE files SET mode = ? WHERE path = ?", mode, path) - if err != nil { - return err - } - - rows, err := result.RowsAffected() - if err != nil { - return err - } - if rows == 0 { - return filesystem.NewNotFoundError("chmod", path) - } - - return nil -} - -func (fs *SQLFS) Open(path string) (io.ReadCloser, error) { - data, err := fs.Read(path, 0, -1) - if err != nil && err != io.EOF { - return nil, err - } - return io.NopCloser(strings.NewReader(string(data))), nil -} - -func (fs *SQLFS) OpenWrite(path string) (io.WriteCloser, error) { - return filesystem.NewBufferedWriter(path, fs.Write), nil -} - -func getReadme() string { - return `SQLFS Plugin - Database-backed File System - -This plugin provides a persistent file system backed by database storage. - -FEATURES: - - Persistent storage (survives server restarts) - - Full POSIX-like file system operations - - Multiple database backends (SQLite, TiDB) - - Efficient database-backed storage - - ACID transactions - - Supports files and directories - - Maximum file size: 5MB per file - -CONFIGURATION: - - SQLite Backend (Local Testing): - [plugins.sqlfs] - enabled = true - path = "/sqlfs" - - [plugins.sqlfs.config] - backend = "sqlite" # or "sqlite3" - db_path = "sqlfs.db" - - # Optional cache settings (enabled by default) - cache_enabled = true # Enable/disable directory listing cache - cache_max_size = 1000 # Maximum number of cached entries (default: 1000) - cache_ttl_seconds = 5 # Cache entry TTL in seconds (default: 5) - - TiDB Backend (Production): - [plugins.sqlfs] - enabled = true - path = "/sqlfs" - - [plugins.sqlfs.config] - backend = "tidb" - - # For TiDB Cloud (TLS required): - user = "3YdGXuXNdAEmP1f.root" - password = "your_password" - host = "gateway01.us-west-2.prod.aws.tidbcloud.com" - port = "4000" - database = "baas" - enable_tls = true - tls_server_name = "gateway01.us-west-2.prod.aws.tidbcloud.com" - - # Or use DSN with TLS: - # dsn = "user:password@tcp(host:4000)/database?charset=utf8mb4&parseTime=True&tls=tidb" - -USAGE: - - Create a directory: - agfs mkdir /sqlfs/mydir - - Create a file: - agfs write /sqlfs/mydir/file.txt "Hello, World!" - - Read a file: - agfs cat /sqlfs/mydir/file.txt - - List directory: - agfs ls /sqlfs/mydir - - Get file info: - agfs stat /sqlfs/mydir/file.txt - - Rename file: - agfs mv /sqlfs/mydir/file.txt /sqlfs/mydir/newfile.txt - - Change permissions: - agfs chmod 755 /sqlfs/mydir/file.txt - - Remove file: - agfs rm /sqlfs/mydir/file.txt - - Remove directory (must be empty): - agfs rm /sqlfs/mydir - - Remove directory recursively: - agfs rm -r /sqlfs/mydir - -EXAMPLES: - - # Create directory structure - agfs:/> mkdir /sqlfs/data - agfs:/> mkdir /sqlfs/data/logs - - # Write files - agfs:/> echo "Configuration data" > /sqlfs/data/config.txt - agfs:/> echo "Log entry" > /sqlfs/data/logs/app.log - - # Read files - agfs:/> cat /sqlfs/data/config.txt - Configuration data - - # List directory - agfs:/> ls /sqlfs/data - config.txt - logs/ - -ADVANTAGES: - - Data persists across server restarts - - Efficient storage with database compression - - Transaction safety (ACID properties) - - Query capabilities (can be extended) - - Backup friendly (single database file) - - Fast directory listing with LRU cache (improves shell completion) - -USE CASES: - - Persistent configuration storage - - Log file storage - - Document management - - Application data storage - - Backup and archival - - Development and testing with persistent data - -TECHNICAL DETAILS: - - Database: SQLite 3 / TiDB (MySQL-compatible) - - Journal mode: WAL (Write-Ahead Logging) for SQLite - - Schema: Single table with path, metadata, and blob data - - Concurrent reads supported - - Write serialization via mutex - - Path normalization and validation - - LRU cache for directory listings (configurable TTL and size) - - Automatic cache invalidation on modifications - -LIMITATIONS: - - Maximum file size: 5MB per file - - Not suitable for large files (use MemFS or StreamFS for larger data) - - Write operations are serialized - - No file locking mechanism - - No sparse file support - - No streaming support (use StreamFS for real-time streaming) -` -} - -// Ensure SQLFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*SQLFSPlugin)(nil) -var _ filesystem.FileSystem = (*SQLFS)(nil) diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/README.md b/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/README.md deleted file mode 100644 index ee64ec9bf..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/README.md +++ /dev/null @@ -1,261 +0,0 @@ -# SQLFS2 Plugin - Plan 9 Style SQL File System - -A session-based SQL interface inspired by Plan 9's file system philosophy. Execute SQL queries by reading and writing virtual files. - -## Features - -- **Plan 9 Style Interface**: Control databases through file operations -- **Session-based Operations**: Each session maintains its own transaction context -- **Multiple Session Levels**: Root, database, and table-bound sessions -- **JSON Data Import**: Bulk insert data via the `data` file -- **Transaction Support**: Sessions operate within database transactions -- **Multiple Backends**: SQLite, MySQL, TiDB - -## Directory Structure - -``` -/sqlfs2/ -├── ctl # Root-level session control -├── / # Root-level session directory -│ ├── ctl # Write "close" to close session -│ ├── query # Write SQL to execute -│ ├── result # Read query results (JSON) -│ └── error # Read error messages -│ -└── / - ├── ctl # Database-level session control - ├── / # Database-level session directory - │ ├── ctl - │ ├── query - │ ├── result - │ └── error - │ - └── / - ├── ctl # Table-level session control - ├── schema # Read table schema (DDL) - ├── count # Read row count - └── / # Table-level session directory - ├── ctl - ├── query - ├── result - ├── error - └── data # Write JSON to insert rows -``` - -## Session Levels - -| Level | Path | Bound To | Files | -|-------|------|----------|-------| -| Root | `//` | Nothing | ctl, query, result, error | -| Database | `///` | Database | ctl, query, result, error | -| Table | `//
//` | Table | ctl, query, result, error, **data** | - -## Basic Usage - -### Creating a Session - -```bash -# Read 'ctl' to create a new session and get session ID -SID=$(cat /sqlfs2/tidb/ctl) -echo "Session ID: $SID" -``` - -### Executing Queries - -```bash -# Write SQL to query file -echo "SELECT * FROM users WHERE id = 1" > /sqlfs2/tidb/$SID/query - -# Read results (JSON format) -cat /sqlfs2/tidb/$SID/result - -# Check for errors -cat /sqlfs2/tidb/$SID/error -``` - -### Closing a Session - -```bash -# Write "close" to ctl to close the session -echo "close" > /sqlfs2/tidb/$SID/ctl -``` - -## The `data` File (Table-Level Sessions Only) - -The `data` file is **exclusive to table-level sessions** and allows bulk JSON data insertion into the bound table. - -### Why Only Table-Level? - -The `data` file automatically maps JSON fields to table columns. This requires knowing the target table's schema, which is only available when the session is bound to a specific table. - -### Supported JSON Formats - -**1. Single Object** -```bash -echo '{"name": "Alice", "age": 30}' > /sqlfs2/tidb/mydb/users/$SID/data -``` - -**2. JSON Array** -```bash -echo '[{"name": "Alice"}, {"name": "Bob"}]' > /sqlfs2/tidb/mydb/users/$SID/data -``` - -**3. NDJSON (Newline Delimited JSON)** -```bash -cat << 'EOF' > /sqlfs2/tidb/mydb/users/$SID/data -{"name": "Alice", "age": 30} -{"name": "Bob", "age": 25} -{"name": "Charlie", "age": 35} -EOF -``` - -### Example: Bulk Insert - -```bash -# Create table-level session -SID=$(cat /sqlfs2/tidb/mydb/users/ctl) - -# Insert multiple records -cat << 'EOF' > /sqlfs2/tidb/mydb/users/$SID/data -{"id": 1, "name": "Alice", "email": "alice@example.com"} -{"id": 2, "name": "Bob", "email": "bob@example.com"} -{"id": 3, "name": "Charlie", "email": "charlie@example.com"} -EOF - -# Check result -cat /sqlfs2/tidb/mydb/users/$SID/result -# Output: {"rows_affected": 3, "last_insert_id": 3} - -# Close session -echo "close" > /sqlfs2/tidb/mydb/users/$SID/ctl -``` - -## Static Files - -### Schema (Table-Level) -```bash -# Read table DDL -cat /sqlfs2/tidb/mydb/users/schema -# Output: CREATE TABLE users (id INT, name VARCHAR(255), ...) -``` - -### Count (Table-Level) -```bash -# Read row count -cat /sqlfs2/tidb/mydb/users/count -# Output: 42 -``` - -## Configuration - -### Static Configuration (config.yaml) - -```yaml -plugins: - sqlfs2: - - name: tidb - enabled: true - path: /sqlfs2/tidb - config: - backend: tidb - dsn: "user:pass@tcp(host:4000)/database?charset=utf8mb4&parseTime=True" - session_timeout: "30m" # Optional: auto-close idle sessions - - - name: sqlite - enabled: true - path: /sqlfs2/local - config: - backend: sqlite - db_path: "./local.db" -``` - -### Dynamic Mounting - -```bash -# Mount TiDB -agfs:/> mount sqlfs2 /sqlfs2/tidb backend=tidb dsn="user:pass@tcp(host:4000)/db" - -# Mount SQLite -agfs:/> mount sqlfs2 /sqlfs2/local backend=sqlite db_path=/tmp/test.db -``` - -## HTTP API Usage - -```bash -# Create session -SID=$(curl -s "http://localhost:8080/api/v1/files?path=/sqlfs2/tidb/ctl") - -# Execute query (use PUT for write operations) -curl -X PUT "http://localhost:8080/api/v1/files?path=/sqlfs2/tidb/$SID/query" \ - -d "SELECT * FROM users" - -# Read result -curl "http://localhost:8080/api/v1/files?path=/sqlfs2/tidb/$SID/result" - -# Close session -curl -X PUT "http://localhost:8080/api/v1/files?path=/sqlfs2/tidb/$SID/ctl" \ - -d "close" -``` - -## Complete Example - -```bash -# 1. Create database-level session -SID=$(cat /sqlfs2/tidb/ctl) -echo "Created session: $SID" - -# 2. Create a table -echo "CREATE TABLE IF NOT EXISTS test_users ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(100), - email VARCHAR(255) -)" > /sqlfs2/tidb/$SID/query - -# 3. Check for errors -cat /sqlfs2/tidb/$SID/error - -# 4. Insert data -echo "INSERT INTO test_users (name, email) VALUES ('Alice', 'alice@test.com')" \ - > /sqlfs2/tidb/$SID/query - -# 5. Query data -echo "SELECT * FROM test_users" > /sqlfs2/tidb/$SID/query -cat /sqlfs2/tidb/$SID/result -# Output: -# [ -# { -# "id": 1, -# "name": "Alice", -# "email": "alice@test.com" -# } -# ] - -# 6. Close session -echo "close" > /sqlfs2/tidb/$SID/ctl -``` - -## Supported Query Types - -| Query Type | Supported | Result Format | -|------------|-----------|---------------| -| SELECT | Yes | JSON array of objects | -| SHOW | Yes | JSON array of objects | -| DESCRIBE | Yes | JSON array of objects | -| EXPLAIN | Yes | JSON array of objects | -| INSERT | Yes | `{"rows_affected": N, "last_insert_id": N}` | -| UPDATE | Yes | `{"rows_affected": N, "last_insert_id": 0}` | -| DELETE | Yes | `{"rows_affected": N, "last_insert_id": 0}` | -| CREATE | Yes | `{"rows_affected": 0, "last_insert_id": 0}` | -| DROP | Yes | `{"rows_affected": 0, "last_insert_id": 0}` | - -## Limitations - -- Sessions are not persistent across server restarts -- Large result sets are fully loaded into memory -- No streaming support for query results -- The `data` file only supports INSERT operations (no UPDATE/DELETE) -- JSON field names must match column names exactly - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend.go deleted file mode 100644 index 34c9f3510..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend.go +++ /dev/null @@ -1,47 +0,0 @@ -package sqlfs2 - -import "database/sql" - -// Backend defines the interface for different database backends -type Backend interface { - // Initialize creates and returns a database connection - Initialize(cfg map[string]interface{}) (*sql.DB, error) - - // GetTableSchema retrieves the CREATE TABLE statement for a table - GetTableSchema(db *sql.DB, dbName, tableName string) (string, error) - - // ListDatabases returns a list of all databases - ListDatabases(db *sql.DB) ([]string, error) - - // ListTables returns a list of all tables in a database - ListTables(db *sql.DB, dbName string) ([]string, error) - - // SwitchDatabase switches to the specified database (no-op for SQLite) - SwitchDatabase(db *sql.DB, dbName string) error - - // GetTableColumns retrieves column names and types for a table - GetTableColumns(db *sql.DB, dbName, tableName string) ([]ColumnInfo, error) - - // Name returns the backend name - Name() string -} - -// ColumnInfo contains information about a table column -type ColumnInfo struct { - Name string - Type string -} - -// newBackend creates a backend instance based on the backend type -func newBackend(backendType string) Backend { - switch backendType { - case "sqlite", "sqlite3": - return &SQLiteBackend{} - case "mysql": - return &MySQLBackend{} - case "tidb": - return &TiDBBackend{} - default: - return nil - } -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_mysql.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_mysql.go deleted file mode 100644 index d7e7b445a..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_mysql.go +++ /dev/null @@ -1,141 +0,0 @@ -package sqlfs2 - -import ( - "database/sql" - "fmt" - - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - _ "github.com/go-sql-driver/mysql" -) - -// MySQLBackend implements the Backend interface for MySQL -type MySQLBackend struct{} - -func (b *MySQLBackend) Name() string { - return "mysql" -} - -func (b *MySQLBackend) Initialize(cfg map[string]interface{}) (*sql.DB, error) { - var dsn string - if dsnStr := config.GetStringConfig(cfg, "dsn", ""); dsnStr != "" { - dsn = dsnStr - } else { - user := config.GetStringConfig(cfg, "user", "root") - password := config.GetStringConfig(cfg, "password", "") - host := config.GetStringConfig(cfg, "host", "127.0.0.1") - port := config.GetStringConfig(cfg, "port", "3306") - database := config.GetStringConfig(cfg, "database", "") - - if password != "" { - dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, password, host, port, database) - } else { - dsn = fmt.Sprintf("%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, host, port, database) - } - } - - db, err := sql.Open("mysql", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open MySQL database: %w", err) - } - return db, nil -} - -func (b *MySQLBackend) GetTableSchema(db *sql.DB, dbName, tableName string) (string, error) { - // Switch to database first if needed - if dbName != "" { - if err := b.SwitchDatabase(db, dbName); err != nil { - return "", err - } - } - - var tblName, createTableStmt string - query := fmt.Sprintf("SHOW CREATE TABLE `%s`", tableName) - err := db.QueryRow(query).Scan(&tblName, &createTableStmt) - if err != nil { - return "", fmt.Errorf("failed to get table schema: %w", err) - } - return createTableStmt, nil -} - -func (b *MySQLBackend) ListDatabases(db *sql.DB) ([]string, error) { - rows, err := db.Query("SHOW DATABASES") - if err != nil { - return nil, fmt.Errorf("failed to list databases: %w", err) - } - defer rows.Close() - - var databases []string - for rows.Next() { - var dbName string - if err := rows.Scan(&dbName); err != nil { - return nil, err - } - databases = append(databases, dbName) - } - return databases, nil -} - -func (b *MySQLBackend) ListTables(db *sql.DB, dbName string) ([]string, error) { - // Switch to database first - if err := b.SwitchDatabase(db, dbName); err != nil { - return nil, err - } - - rows, err := db.Query("SHOW TABLES") - if err != nil { - return nil, fmt.Errorf("failed to list tables: %w", err) - } - defer rows.Close() - - var tables []string - for rows.Next() { - var tableName string - if err := rows.Scan(&tableName); err != nil { - return nil, err - } - tables = append(tables, tableName) - } - return tables, nil -} - -func (b *MySQLBackend) SwitchDatabase(db *sql.DB, dbName string) error { - if dbName == "" { - return nil - } - _, err := db.Exec(fmt.Sprintf("USE `%s`", dbName)) - if err != nil { - return fmt.Errorf("failed to switch to database %s: %w", dbName, err) - } - return nil -} - -func (b *MySQLBackend) GetTableColumns(db *sql.DB, dbName, tableName string) ([]ColumnInfo, error) { - // Switch to database first if needed - if dbName != "" { - if err := b.SwitchDatabase(db, dbName); err != nil { - return nil, err - } - } - - query := fmt.Sprintf("SHOW COLUMNS FROM `%s`", tableName) - rows, err := db.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to get table columns: %w", err) - } - defer rows.Close() - - var columns []ColumnInfo - for rows.Next() { - var field, colType string - var null, key, extra interface{} - var dflt interface{} - - if err := rows.Scan(&field, &colType, &null, &key, &dflt, &extra); err != nil { - return nil, err - } - columns = append(columns, ColumnInfo{Name: field, Type: colType}) - } - return columns, nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_sqlite.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_sqlite.go deleted file mode 100644 index 8f0f5a051..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_sqlite.go +++ /dev/null @@ -1,86 +0,0 @@ -package sqlfs2 - -import ( - "database/sql" - "fmt" - - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - _ "github.com/mattn/go-sqlite3" -) - -// SQLiteBackend implements the Backend interface for SQLite -type SQLiteBackend struct{} - -func (b *SQLiteBackend) Name() string { - return "sqlite" -} - -func (b *SQLiteBackend) Initialize(cfg map[string]interface{}) (*sql.DB, error) { - dbPath := config.GetStringConfig(cfg, "db_path", "sqlfs2.db") - db, err := sql.Open("sqlite3", dbPath) - if err != nil { - return nil, fmt.Errorf("failed to open SQLite database: %w", err) - } - return db, nil -} - -func (b *SQLiteBackend) GetTableSchema(db *sql.DB, dbName, tableName string) (string, error) { - var createTableStmt string - query := "SELECT sql FROM sqlite_master WHERE type='table' AND name=?" - err := db.QueryRow(query, tableName).Scan(&createTableStmt) - if err != nil { - return "", fmt.Errorf("failed to get table schema: %w", err) - } - return createTableStmt, nil -} - -func (b *SQLiteBackend) ListDatabases(db *sql.DB) ([]string, error) { - // SQLite only has one main database - return []string{"main"}, nil -} - -func (b *SQLiteBackend) ListTables(db *sql.DB, dbName string) ([]string, error) { - rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - if err != nil { - return nil, fmt.Errorf("failed to list tables: %w", err) - } - defer rows.Close() - - var tables []string - for rows.Next() { - var tableName string - if err := rows.Scan(&tableName); err != nil { - return nil, err - } - tables = append(tables, tableName) - } - return tables, nil -} - -func (b *SQLiteBackend) SwitchDatabase(db *sql.DB, dbName string) error { - // SQLite doesn't need to switch databases - return nil -} - -func (b *SQLiteBackend) GetTableColumns(db *sql.DB, dbName, tableName string) ([]ColumnInfo, error) { - query := fmt.Sprintf("PRAGMA table_info(%s)", tableName) - rows, err := db.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to get table columns: %w", err) - } - defer rows.Close() - - var columns []ColumnInfo - for rows.Next() { - var cid int - var name, colType string - var notNull, pk int - var dfltValue interface{} - - if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { - return nil, err - } - columns = append(columns, ColumnInfo{Name: name, Type: colType}) - } - return columns, nil -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_tidb.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_tidb.go deleted file mode 100644 index 484bb8a8f..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/backend_tidb.go +++ /dev/null @@ -1,262 +0,0 @@ -package sqlfs2 - -import ( - "crypto/tls" - "database/sql" - "fmt" - "regexp" - "strings" - - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - "github.com/go-sql-driver/mysql" - _ "github.com/go-sql-driver/mysql" - log "github.com/sirupsen/logrus" -) - -// TiDBBackend implements the Backend interface for TiDB -type TiDBBackend struct{} - -func (b *TiDBBackend) Name() string { - return "tidb" -} - -func (b *TiDBBackend) Initialize(cfg map[string]interface{}) (*sql.DB, error) { - // Check if DSN contains tls parameter - dsnStr := config.GetStringConfig(cfg, "dsn", "") - dsnHasTLS := strings.Contains(dsnStr, "tls=") - - // Extract TLS config name from DSN if present - tlsConfigName := "tidb-sqlfs2" - if dsnHasTLS { - // Extract tls parameter value from DSN: tls=value - re := regexp.MustCompile(`tls=([^&]+)`) - if matches := re.FindStringSubmatch(dsnStr); len(matches) > 1 { - tlsConfigName = matches[1] - } - } - - // Register TLS configuration if needed - enableTLS := config.GetBoolConfig(cfg, "enable_tls", false) || dsnHasTLS - - if enableTLS { - // Get TLS configuration - serverName := config.GetStringConfig(cfg, "tls_server_name", "") - - // If no explicit server name, try to extract from DSN or host - if serverName == "" { - if dsnStr != "" { - // Extract host from DSN: user:pass@tcp(host:port)/db - re := regexp.MustCompile(`@tcp\(([^:]+):\d+\)`) - if matches := re.FindStringSubmatch(dsnStr); len(matches) > 1 { - serverName = matches[1] - } - } else { - // Use host config - serverName = config.GetStringConfig(cfg, "host", "") - } - } - - skipVerify := config.GetBoolConfig(cfg, "tls_skip_verify", false) - - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - if serverName != "" { - tlsConfig.ServerName = serverName - } - - if skipVerify { - tlsConfig.InsecureSkipVerify = true - log.Warn("[sqlfs2] TLS certificate verification is disabled (insecure)") - } - - // Register TLS config with MySQL driver - if err := mysql.RegisterTLSConfig(tlsConfigName, tlsConfig); err != nil { - log.Warnf("[sqlfs2] Failed to register TLS config (may already exist): %v", err) - } - } - - // Parse TiDB connection string - var dsn string - - if dsnStr != "" { - dsn = dsnStr - } else { - // Build DSN from individual components - user := config.GetStringConfig(cfg, "user", "root") - password := config.GetStringConfig(cfg, "password", "") - host := config.GetStringConfig(cfg, "host", "127.0.0.1") - port := config.GetStringConfig(cfg, "port", "4000") - database := config.GetStringConfig(cfg, "database", "test") - - // Build base DSN - if password != "" { - dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, password, host, port, database) - } else { - dsn = fmt.Sprintf("%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True", - user, host, port, database) - } - - // Add TLS parameter if enabled - if enableTLS { - dsn += fmt.Sprintf("&tls=%s", tlsConfigName) - } - } - - log.Infof("[sqlfs2] Connecting to TiDB (TLS: %v)", enableTLS) - - // Extract database name to create it if needed - dbName := extractDatabaseName(dsn, config.GetStringConfig(cfg, "database", "")) - - // First, try to connect without database to create it if needed - if dbName != "" { - dsnWithoutDB := removeDatabaseFromDSN(dsn) - if dsnWithoutDB != dsn { - tempDB, err := sql.Open("mysql", dsnWithoutDB) - if err == nil { - defer tempDB.Close() - // Try to create database if it doesn't exist - _, err = tempDB.Exec(fmt.Sprintf("CREATE DATABASE IF NOT EXISTS `%s`", dbName)) - if err != nil { - log.Warnf("[sqlfs2] Failed to create database '%s': %v", dbName, err) - } - } - } - } - - db, err := sql.Open("mysql", dsn) - if err != nil { - return nil, fmt.Errorf("failed to open TiDB database: %w", err) - } - - // Set connection pool parameters - db.SetMaxOpenConns(100) - db.SetMaxIdleConns(10) - - // Test connection - if err := db.Ping(); err != nil { - db.Close() - return nil, fmt.Errorf("failed to ping TiDB database: %w", err) - } - - return db, nil -} - -func (b *TiDBBackend) GetTableSchema(db *sql.DB, dbName, tableName string) (string, error) { - // Switch to database first if needed - if dbName != "" { - if err := b.SwitchDatabase(db, dbName); err != nil { - return "", err - } - } - - var tblName, createTableStmt string - query := fmt.Sprintf("SHOW CREATE TABLE `%s`", tableName) - err := db.QueryRow(query).Scan(&tblName, &createTableStmt) - if err != nil { - return "", fmt.Errorf("failed to get table schema: %w", err) - } - return createTableStmt, nil -} - -func (b *TiDBBackend) ListDatabases(db *sql.DB) ([]string, error) { - rows, err := db.Query("SHOW DATABASES") - if err != nil { - return nil, fmt.Errorf("failed to list databases: %w", err) - } - defer rows.Close() - - var databases []string - for rows.Next() { - var dbName string - if err := rows.Scan(&dbName); err != nil { - return nil, err - } - databases = append(databases, dbName) - } - return databases, nil -} - -func (b *TiDBBackend) ListTables(db *sql.DB, dbName string) ([]string, error) { - // Switch to database first - if err := b.SwitchDatabase(db, dbName); err != nil { - return nil, err - } - - rows, err := db.Query("SHOW TABLES") - if err != nil { - return nil, fmt.Errorf("failed to list tables: %w", err) - } - defer rows.Close() - - var tables []string - for rows.Next() { - var tableName string - if err := rows.Scan(&tableName); err != nil { - return nil, err - } - tables = append(tables, tableName) - } - return tables, nil -} - -func (b *TiDBBackend) SwitchDatabase(db *sql.DB, dbName string) error { - if dbName == "" { - return nil - } - _, err := db.Exec(fmt.Sprintf("USE `%s`", dbName)) - if err != nil { - return fmt.Errorf("failed to switch to database %s: %w", dbName, err) - } - return nil -} - -func (b *TiDBBackend) GetTableColumns(db *sql.DB, dbName, tableName string) ([]ColumnInfo, error) { - // Switch to database first if needed - if dbName != "" { - if err := b.SwitchDatabase(db, dbName); err != nil { - return nil, err - } - } - - query := fmt.Sprintf("SHOW COLUMNS FROM `%s`", tableName) - rows, err := db.Query(query) - if err != nil { - return nil, fmt.Errorf("failed to get table columns: %w", err) - } - defer rows.Close() - - var columns []ColumnInfo - for rows.Next() { - var field, colType string - var null, key, extra interface{} - var dflt interface{} - - if err := rows.Scan(&field, &colType, &null, &key, &dflt, &extra); err != nil { - return nil, err - } - columns = append(columns, ColumnInfo{Name: field, Type: colType}) - } - return columns, nil -} - -// extractDatabaseName extracts database name from DSN or config -func extractDatabaseName(dsn string, configDB string) string { - if dsn != "" { - // Extract from DSN: ...)/database?... - re := regexp.MustCompile(`\)/([^?]+)`) - if matches := re.FindStringSubmatch(dsn); len(matches) > 1 { - return matches[1] - } - } - return configDB -} - -// removeDatabaseFromDSN removes database name from DSN -func removeDatabaseFromDSN(dsn string) string { - // Replace )/database? with )/? - re := regexp.MustCompile(`\)/[^?]+(\?|$)`) - return re.ReplaceAllString(dsn, ")/$1") -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/sqlfs2.go b/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/sqlfs2.go deleted file mode 100644 index b7cfeb2e4..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/sqlfs2/sqlfs2.go +++ /dev/null @@ -1,2739 +0,0 @@ -package sqlfs2 - -import ( - "bytes" - "database/sql" - "encoding/json" - "fmt" - "io" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "sqlfs2" -) - -// Session represents a Plan 9 style session for SQL operations -type Session struct { - id int64 // Numeric session ID - dbName string - tableName string - tx *sql.Tx // SQL transaction - result []byte // Query result (JSON) - lastError string // Error message - lastAccess time.Time // Last access time - mu sync.Mutex -} - -// Touch updates the last access time. Must be called with mu held. -func (s *Session) Touch() { - s.lastAccess = time.Now() -} - -// UnlockWithTouch updates lastAccess and releases the lock. -// This ensures that long-running operations don't cause the session -// to be incorrectly marked as expired by the cleanup goroutine. -func (s *Session) UnlockWithTouch() { - s.lastAccess = time.Now() - s.mu.Unlock() -} - -// SessionManager manages all active sessions -type SessionManager struct { - sessions map[string]*Session // key: "dbName/tableName/sid" - nextID int64 - timeout time.Duration // Configurable timeout (0 = no timeout) - mu sync.RWMutex - stopCh chan struct{} -} - -// NewSessionManager creates a new session manager -func NewSessionManager(timeout time.Duration) *SessionManager { - sm := &SessionManager{ - sessions: make(map[string]*Session), - nextID: 1, - timeout: timeout, - stopCh: make(chan struct{}), - } - if timeout > 0 { - go sm.cleanupLoop() - } - return sm -} - -// cleanupLoop periodically cleans up expired sessions -func (sm *SessionManager) cleanupLoop() { - ticker := time.NewTicker(sm.timeout / 2) - defer ticker.Stop() - for { - select { - case <-ticker.C: - sm.cleanupExpired() - case <-sm.stopCh: - return - } - } -} - -// cleanupExpired removes expired sessions -func (sm *SessionManager) cleanupExpired() { - sm.mu.Lock() - defer sm.mu.Unlock() - - now := time.Now() - for key, session := range sm.sessions { - session.mu.Lock() - if now.Sub(session.lastAccess) > sm.timeout { - if session.tx != nil { - session.tx.Rollback() - } - delete(sm.sessions, key) - log.Debugf("[sqlfs2] Session %d expired and cleaned up", session.id) - } - session.mu.Unlock() - } -} - -// Stop stops the cleanup goroutine -func (sm *SessionManager) Stop() { - if sm.timeout > 0 { - close(sm.stopCh) - } -} - -// CreateSession creates a new session for the given db/table -func (sm *SessionManager) CreateSession(db *sql.DB, dbName, tableName string) (*Session, error) { - sm.mu.Lock() - defer sm.mu.Unlock() - - // Start a new transaction - tx, err := db.Begin() - if err != nil { - return nil, fmt.Errorf("failed to begin transaction: %w", err) - } - - id := sm.nextID - sm.nextID++ - - session := &Session{ - id: id, - dbName: dbName, - tableName: tableName, - tx: tx, - lastAccess: time.Now(), - } - - key := fmt.Sprintf("%s/%s/%d", dbName, tableName, id) - sm.sessions[key] = session - - log.Debugf("[sqlfs2] Created session %d for %s.%s", id, dbName, tableName) - return session, nil -} - -// GetSession retrieves a session by db/table/id -func (sm *SessionManager) GetSession(dbName, tableName, sid string) *Session { - sm.mu.RLock() - defer sm.mu.RUnlock() - - key := fmt.Sprintf("%s/%s/%s", dbName, tableName, sid) - session := sm.sessions[key] - if session != nil { - session.mu.Lock() - session.lastAccess = time.Now() - session.mu.Unlock() - } - return session -} - -// CloseSession closes and removes a session -func (sm *SessionManager) CloseSession(dbName, tableName, sid string) error { - sm.mu.Lock() - defer sm.mu.Unlock() - - key := fmt.Sprintf("%s/%s/%s", dbName, tableName, sid) - session, exists := sm.sessions[key] - if !exists { - return fmt.Errorf("session not found: %s", sid) - } - - session.mu.Lock() - defer session.UnlockWithTouch() - - if session.tx != nil { - session.tx.Rollback() - } - delete(sm.sessions, key) - - log.Debugf("[sqlfs2] Closed session %d", session.id) - return nil -} - -// ListSessions returns all session IDs for a given db/table -func (sm *SessionManager) ListSessions(dbName, tableName string) []string { - sm.mu.RLock() - defer sm.mu.RUnlock() - - prefix := fmt.Sprintf("%s/%s/", dbName, tableName) - var sids []string - for key := range sm.sessions { - if strings.HasPrefix(key, prefix) { - sid := strings.TrimPrefix(key, prefix) - sids = append(sids, sid) - } - } - return sids -} - -// SQLFS2Plugin provides a SQL interface through file system operations -// Directory structure: /sqlfs2///{ctl, schema, count, /...} -type SQLFS2Plugin struct { - db *sql.DB - backend Backend - config map[string]interface{} - sessionManager *SessionManager // Shared across all filesystem instances -} - -// NewSQLFS2Plugin creates a new SQLFS2 plugin -func NewSQLFS2Plugin() *SQLFS2Plugin { - return &SQLFS2Plugin{} -} - -func (p *SQLFS2Plugin) Name() string { - return PluginName -} - -func (p *SQLFS2Plugin) Validate(cfg map[string]interface{}) error { - allowedKeys := []string{"backend", "db_path", "dsn", "user", "password", "host", "port", "database", - "enable_tls", "tls_server_name", "tls_skip_verify", "mount_path", "session_timeout"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate backend type - backendType := config.GetStringConfig(cfg, "backend", "sqlite") - validBackends := map[string]bool{ - "sqlite": true, - "sqlite3": true, - "mysql": true, - "tidb": true, - } - if !validBackends[backendType] { - return fmt.Errorf("unsupported database backend: %s (valid options: sqlite, sqlite3, mysql, tidb)", backendType) - } - - // Validate optional string parameters - for _, key := range []string{"db_path", "dsn", "user", "password", "host", "database", "tls_server_name"} { - if err := config.ValidateStringType(cfg, key); err != nil { - return err - } - } - - // Validate optional integer parameters - for _, key := range []string{"port"} { - if err := config.ValidateIntType(cfg, key); err != nil { - return err - } - } - - // Validate optional boolean parameters - for _, key := range []string{"enable_tls", "tls_skip_verify"} { - if err := config.ValidateBoolType(cfg, key); err != nil { - return err - } - } - - return nil -} - -func (p *SQLFS2Plugin) Initialize(cfg map[string]interface{}) error { - p.config = cfg - - backendType := config.GetStringConfig(cfg, "backend", "sqlite") - - // Create backend instance - backend := newBackend(backendType) - if backend == nil { - return fmt.Errorf("unsupported backend: %s", backendType) - } - p.backend = backend - - // Initialize database connection using the backend - db, err := backend.Initialize(cfg) - if err != nil { - return fmt.Errorf("failed to initialize %s backend: %w", backendType, err) - } - p.db = db - - // Initialize session manager (shared across all filesystem instances) - var timeout time.Duration - if timeoutStr := config.GetStringConfig(cfg, "session_timeout", ""); timeoutStr != "" { - if parsed, err := time.ParseDuration(timeoutStr); err == nil { - timeout = parsed - } - } - p.sessionManager = NewSessionManager(timeout) - - log.Infof("[sqlfs2] Initialized with backend: %s", backendType) - return nil -} - -func (p *SQLFS2Plugin) GetFileSystem() filesystem.FileSystem { - return &sqlfs2FS{ - plugin: p, - handles: make(map[int64]*SQLFileHandle), - nextHandleID: 1, - sessionManager: p.sessionManager, // Use shared session manager - } -} - -func (p *SQLFS2Plugin) GetReadme() string { - return getReadme() -} - -func (p *SQLFS2Plugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "backend", - Type: "string", - Required: false, - Default: "sqlite", - Description: "Database backend (sqlite, sqlite3, mysql, tidb)", - }, - { - Name: "db_path", - Type: "string", - Required: false, - Default: "", - Description: "Database file path (for SQLite)", - }, - { - Name: "dsn", - Type: "string", - Required: false, - Default: "", - Description: "Database connection string (DSN)", - }, - { - Name: "user", - Type: "string", - Required: false, - Default: "", - Description: "Database username", - }, - { - Name: "password", - Type: "string", - Required: false, - Default: "", - Description: "Database password", - }, - { - Name: "host", - Type: "string", - Required: false, - Default: "", - Description: "Database host", - }, - { - Name: "port", - Type: "int", - Required: false, - Default: "", - Description: "Database port", - }, - { - Name: "database", - Type: "string", - Required: false, - Default: "", - Description: "Database name", - }, - { - Name: "enable_tls", - Type: "bool", - Required: false, - Default: "false", - Description: "Enable TLS for database connection", - }, - { - Name: "tls_server_name", - Type: "string", - Required: false, - Default: "", - Description: "TLS server name for verification", - }, - { - Name: "tls_skip_verify", - Type: "bool", - Required: false, - Default: "false", - Description: "Skip TLS certificate verification", - }, - { - Name: "session_timeout", - Type: "string", - Required: false, - Default: "", - Description: "Session timeout duration (e.g., '10m', '1h'). Empty means no timeout.", - }, - } -} - -func (p *SQLFS2Plugin) Shutdown() error { - if p.sessionManager != nil { - p.sessionManager.Stop() - } - if p.db != nil { - return p.db.Close() - } - return nil -} - -// sqlfs2FS implements the FileSystem interface for SQL operations -type sqlfs2FS struct { - plugin *SQLFS2Plugin - handles map[int64]*SQLFileHandle - handlesMu sync.RWMutex - nextHandleID int64 - sessionManager *SessionManager -} - -// isSessionID checks if the given string is a numeric session ID -func isSessionID(s string) bool { - if s == "" { - return false - } - for _, c := range s { - if c < '0' || c > '9' { - return false - } - } - return true -} - -// isTableLevelFile checks if the given name is a table-level special file -func isTableLevelFile(name string) bool { - return name == "ctl" || name == "schema" || name == "count" -} - -// isRootLevelFile checks if the given name is a root-level special file -func isRootLevelFile(name string) bool { - return name == "ctl" -} - -// isSessionFile checks if the given name is a session-level file -func isSessionFile(name string) bool { - return name == "ctl" || name == "query" || name == "result" || name == "data" || name == "error" -} - -// isDatabaseLevelFile checks if the given name is a database-level special file -func isDatabaseLevelFile(name string) bool { - return name == "ctl" -} - -// parsePath parses a path into (dbName, tableName, sid, operation) -// Supported paths: -// / -> ("", "", "", "") -// /ctl -> ("", "", "", "ctl") - root level ctl -// / -> ("", "", sid, "") - root level session -// //query -> ("", "", sid, "query") -// /dbName -> (dbName, "", "", "") -// /dbName/ctl -> (dbName, "", "", "ctl") - database level ctl -// /dbName/ -> (dbName, "", sid, "") - database level session -// /dbName//query -> (dbName, "", sid, "query") - database level session file -// /dbName/tableName -> (dbName, tableName, "", "") -// /dbName/tableName/ctl -> (dbName, tableName, "", "ctl") -// /dbName/tableName/schema -> (dbName, tableName, "", "schema") -// /dbName/tableName/count -> (dbName, tableName, "", "count") -// /dbName/tableName/ -> (dbName, tableName, sid, "") -// /dbName/tableName//query -> (dbName, tableName, sid, "query") -// /dbName/tableName//result -> (dbName, tableName, sid, "result") -// /dbName/tableName//ctl -> (dbName, tableName, sid, "ctl") -// /dbName/tableName//data -> (dbName, tableName, sid, "data") -// /dbName/tableName//error -> (dbName, tableName, sid, "error") -func (fs *sqlfs2FS) parsePath(path string) (dbName, tableName, sid, operation string, err error) { - path = strings.TrimPrefix(path, "/") - parts := strings.Split(path, "/") - - if len(parts) == 0 || path == "" { - // Root directory - return "", "", "", "", nil - } - - if len(parts) == 1 { - // Could be: - // - /ctl -> root level ctl file - // - / -> root level session directory - // - /dbName -> database directory - if isRootLevelFile(parts[0]) { - return "", "", "", parts[0], nil - } - if isSessionID(parts[0]) { - return "", "", parts[0], "", nil - } - // Database level: /dbName - return parts[0], "", "", "", nil - } - - if len(parts) == 2 { - // Could be: - // - //query -> root level session file - // - /dbName/ctl -> database level ctl file - // - /dbName/ -> database level session directory - // - /dbName/tableName -> table directory - if isSessionID(parts[0]) && isSessionFile(parts[1]) { - return "", "", parts[0], parts[1], nil - } - if isDatabaseLevelFile(parts[1]) { - // Database level ctl: /dbName/ctl - return parts[0], "", "", parts[1], nil - } - if isSessionID(parts[1]) { - // Database level session: /dbName/ - return parts[0], "", parts[1], "", nil - } - // Table level: /dbName/tableName - return parts[0], parts[1], "", "", nil - } - - if len(parts) == 3 { - // Could be: - // - /dbName//query -> database level session file - // - /dbName/tableName/ctl -> table-level ctl - // - /dbName/tableName/schema -> table-level schema - // - /dbName/tableName/count -> table-level count - // - /dbName/tableName/ -> session directory - if isSessionID(parts[1]) && isSessionFile(parts[2]) { - // Database level session file: /dbName//query - return parts[0], "", parts[1], parts[2], nil - } - if isTableLevelFile(parts[2]) { - return parts[0], parts[1], "", parts[2], nil - } - if isSessionID(parts[2]) { - return parts[0], parts[1], parts[2], "", nil - } - return "", "", "", "", fmt.Errorf("invalid path component: %s", parts[2]) - } - - if len(parts) == 4 { - // Session-level file: /dbName/tableName//operation - if !isSessionID(parts[2]) { - return "", "", "", "", fmt.Errorf("invalid session ID: %s", parts[2]) - } - return parts[0], parts[1], parts[2], parts[3], nil - } - - return "", "", "", "", fmt.Errorf("invalid path: %s", path) -} - -// tableExists checks if a table exists in the specified database -func (fs *sqlfs2FS) tableExists(dbName, tableName string) (bool, error) { - if dbName == "" || tableName == "" { - return false, fmt.Errorf("dbName and tableName must not be empty") - } - - tables, err := fs.plugin.backend.ListTables(fs.plugin.db, dbName) - if err != nil { - return false, err - } - - for _, t := range tables { - if t == tableName { - return true, nil - } - } - - return false, nil -} - -func (fs *sqlfs2FS) Read(path string, offset int64, size int64) ([]byte, error) { - dbName, tableName, sid, operation, err := fs.parsePath(path) - if err != nil { - return nil, err - } - - // Root-level files (no db, no table, no session) - if dbName == "" && tableName == "" && sid == "" { - switch operation { - case "ctl": - // Root-level ctl: creates a global session (no table binding) - session, err := fs.sessionManager.CreateSession(fs.plugin.db, "", "") - if err != nil { - return nil, err - } - data := []byte(fmt.Sprintf("%d\n", session.id)) - return plugin.ApplyRangeRead(data, offset, size) - - case "": - // Root directory - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - - default: - return nil, fmt.Errorf("unknown root-level file: %s", operation) - } - } - - // Root-level session files (no db, no table, but has session) - if dbName == "" && tableName == "" && sid != "" { - session := fs.sessionManager.GetSession("", "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - switch operation { - case "result": - session.mu.Lock() - result := session.result - session.mu.Unlock() - - if result == nil { - return []byte{}, nil - } - return plugin.ApplyRangeRead(result, offset, size) - - case "error": - session.mu.Lock() - errMsg := session.lastError - session.mu.Unlock() - - if errMsg == "" { - return []byte{}, nil - } - data := []byte(errMsg + "\n") - return plugin.ApplyRangeRead(data, offset, size) - - case "query", "data", "ctl": - return nil, fmt.Errorf("%s is write-only", operation) - - case "": - // Session directory - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - - default: - return nil, fmt.Errorf("unknown session file: %s", operation) - } - } - - // Database-level files (has db, no table, no session) - if dbName != "" && tableName == "" && sid == "" { - switch operation { - case "ctl": - // Database-level ctl: creates a database-scoped session (no table binding) - // Switch to database if needed - if err := fs.plugin.backend.SwitchDatabase(fs.plugin.db, dbName); err != nil { - return nil, err - } - - session, err := fs.sessionManager.CreateSession(fs.plugin.db, dbName, "") - if err != nil { - return nil, err - } - data := []byte(fmt.Sprintf("%d\n", session.id)) - return plugin.ApplyRangeRead(data, offset, size) - - case "": - // Database directory - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - - default: - return nil, fmt.Errorf("unknown database-level file: %s", operation) - } - } - - // Database-level session files (has db, no table, but has session) - if dbName != "" && tableName == "" && sid != "" { - session := fs.sessionManager.GetSession(dbName, "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - switch operation { - case "result": - session.mu.Lock() - result := session.result - session.mu.Unlock() - - if result == nil { - return []byte{}, nil - } - return plugin.ApplyRangeRead(result, offset, size) - - case "error": - session.mu.Lock() - errMsg := session.lastError - session.mu.Unlock() - - if errMsg == "" { - return []byte{}, nil - } - data := []byte(errMsg + "\n") - return plugin.ApplyRangeRead(data, offset, size) - - case "query", "data", "ctl": - return nil, fmt.Errorf("%s is write-only", operation) - - case "": - // Session directory - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - - default: - return nil, fmt.Errorf("unknown session file: %s", operation) - } - } - - // Table-level files (no session) - if sid == "" { - switch operation { - case "ctl": - // Reading ctl creates a new session and returns the session ID - if dbName == "" || tableName == "" { - return nil, fmt.Errorf("invalid path for ctl: %s", path) - } - - // Check if table exists - exists, err := fs.tableExists(dbName, tableName) - if err != nil { - return nil, fmt.Errorf("failed to check table existence: %w", err) - } - if !exists { - return nil, fmt.Errorf("table '%s.%s' does not exist", dbName, tableName) - } - - // Switch to database if needed - if err := fs.plugin.backend.SwitchDatabase(fs.plugin.db, dbName); err != nil { - return nil, err - } - - // Create new session - session, err := fs.sessionManager.CreateSession(fs.plugin.db, dbName, tableName) - if err != nil { - return nil, err - } - - data := []byte(fmt.Sprintf("%d\n", session.id)) - return plugin.ApplyRangeRead(data, offset, size) - - case "schema": - if dbName == "" || tableName == "" { - return nil, fmt.Errorf("invalid path for schema: %s", path) - } - - createTableStmt, err := fs.plugin.backend.GetTableSchema(fs.plugin.db, dbName, tableName) - if err != nil { - return nil, err - } - - data := []byte(createTableStmt + "\n") - return plugin.ApplyRangeRead(data, offset, size) - - case "count": - if dbName == "" || tableName == "" { - return nil, fmt.Errorf("invalid path for count: %s", path) - } - - if err := fs.plugin.backend.SwitchDatabase(fs.plugin.db, dbName); err != nil { - return nil, err - } - - sqlStmt := fmt.Sprintf("SELECT COUNT(*) FROM %s.%s", dbName, tableName) - var count int64 - err := fs.plugin.db.QueryRow(sqlStmt).Scan(&count) - if err != nil { - return nil, fmt.Errorf("count query error: %w", err) - } - - data := []byte(fmt.Sprintf("%d\n", count)) - return plugin.ApplyRangeRead(data, offset, size) - - case "": - // Directory read - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - - default: - return nil, fmt.Errorf("unknown table-level file: %s", operation) - } - } - - // Session-level files (table-bound sessions) - session := fs.sessionManager.GetSession(dbName, tableName, sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - switch operation { - case "result": - session.mu.Lock() - result := session.result - session.mu.Unlock() - - if result == nil { - return []byte{}, nil - } - return plugin.ApplyRangeRead(result, offset, size) - - case "error": - session.mu.Lock() - errMsg := session.lastError - session.mu.Unlock() - - if errMsg == "" { - return []byte{}, nil - } - data := []byte(errMsg + "\n") - return plugin.ApplyRangeRead(data, offset, size) - - case "query", "data", "ctl": - return nil, fmt.Errorf("%s is write-only", operation) - - case "": - // Session directory - return nil, filesystem.NewInvalidArgumentError("path", path, "is a directory") - - default: - return nil, fmt.Errorf("unknown session file: %s", operation) - } -} - -func (fs *sqlfs2FS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - dbName, tableName, sid, operation, err := fs.parsePath(path) - if err != nil { - return 0, err - } - - // Root-level files (no db, no table, no session) - if dbName == "" && tableName == "" && sid == "" { - switch operation { - case "": - return 0, fmt.Errorf("cannot write to directory: %s", path) - case "ctl": - return 0, fmt.Errorf("ctl is read-only") - default: - return 0, fmt.Errorf("unknown root-level file: %s", operation) - } - } - - // Root-level session files (no db, no table, but has session) - if dbName == "" && tableName == "" && sid != "" { - session := fs.sessionManager.GetSession("", "", sid) - if session == nil { - return 0, fmt.Errorf("session not found: %s", sid) - } - - session.mu.Lock() - defer session.UnlockWithTouch() - - switch operation { - case "ctl": - cmd := strings.TrimSpace(string(data)) - if cmd == "close" { - session.mu.Unlock() - err := fs.sessionManager.CloseSession("", "", sid) - session.mu.Lock() - if err != nil { - return 0, err - } - return int64(len(data)), nil - } - return 0, fmt.Errorf("unknown ctl command: %s", cmd) - - case "query": - // Execute SQL query and store result - sqlStmt := strings.TrimSpace(string(data)) - if sqlStmt == "" { - session.lastError = "empty SQL statement" - return 0, fmt.Errorf("empty SQL statement") - } - - // Determine if this is a SELECT query - upperSQL := strings.ToUpper(sqlStmt) - isSelect := strings.HasPrefix(upperSQL, "SELECT") || - strings.HasPrefix(upperSQL, "SHOW") || - strings.HasPrefix(upperSQL, "DESCRIBE") || - strings.HasPrefix(upperSQL, "EXPLAIN") - - if isSelect { - rows, err := session.tx.Query(sqlStmt) - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("query error: %w", err) - } - defer rows.Close() - - columns, err := rows.Columns() - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("failed to get columns: %w", err) - } - - var results []map[string]interface{} - for rows.Next() { - values := make([]interface{}, len(columns)) - valuePtrs := make([]interface{}, len(columns)) - for i := range values { - valuePtrs[i] = &values[i] - } - - if err := rows.Scan(valuePtrs...); err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("scan error: %w", err) - } - - row := make(map[string]interface{}) - for i, col := range columns { - val := values[i] - if b, ok := val.([]byte); ok { - row[col] = string(b) - } else { - row[col] = val - } - } - results = append(results, row) - } - - if err := rows.Err(); err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("rows error: %w", err) - } - - jsonData, err := json.MarshalIndent(results, "", " ") - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("json marshal error: %w", err) - } - session.result = append(jsonData, '\n') - session.lastError = "" - } else { - result, err := session.tx.Exec(sqlStmt) - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("execution error: %w", err) - } - - rowsAffected, _ := result.RowsAffected() - lastInsertId, _ := result.LastInsertId() - - resultMap := map[string]interface{}{ - "rows_affected": rowsAffected, - "last_insert_id": lastInsertId, - } - jsonData, _ := json.MarshalIndent(resultMap, "", " ") - session.result = append(jsonData, '\n') - session.lastError = "" - } - - return int64(len(data)), nil - - case "result", "error": - return 0, fmt.Errorf("%s is read-only", operation) - - case "": - return 0, fmt.Errorf("cannot write to directory: %s", path) - - default: - return 0, fmt.Errorf("unknown session file: %s", operation) - } - } - - // Database-level files (has db, no table, no session) - if dbName != "" && tableName == "" && sid == "" { - switch operation { - case "": - return 0, fmt.Errorf("cannot write to directory: %s", path) - case "ctl": - return 0, fmt.Errorf("ctl is read-only") - default: - return 0, fmt.Errorf("unknown database-level file: %s", operation) - } - } - - // Database-level session files (has db, no table, but has session) - if dbName != "" && tableName == "" && sid != "" { - session := fs.sessionManager.GetSession(dbName, "", sid) - if session == nil { - return 0, fmt.Errorf("session not found: %s", sid) - } - - session.mu.Lock() - defer session.UnlockWithTouch() - - switch operation { - case "ctl": - cmd := strings.TrimSpace(string(data)) - if cmd == "close" { - session.mu.Unlock() - err := fs.sessionManager.CloseSession(dbName, "", sid) - session.mu.Lock() - if err != nil { - return 0, err - } - return int64(len(data)), nil - } - return 0, fmt.Errorf("unknown ctl command: %s", cmd) - - case "query": - // Execute SQL query and store result - sqlStmt := strings.TrimSpace(string(data)) - if sqlStmt == "" { - session.lastError = "empty SQL statement" - return 0, fmt.Errorf("empty SQL statement") - } - - // Determine if this is a SELECT query - upperSQL := strings.ToUpper(sqlStmt) - isSelect := strings.HasPrefix(upperSQL, "SELECT") || - strings.HasPrefix(upperSQL, "SHOW") || - strings.HasPrefix(upperSQL, "DESCRIBE") || - strings.HasPrefix(upperSQL, "EXPLAIN") - - if isSelect { - rows, err := session.tx.Query(sqlStmt) - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("query error: %w", err) - } - defer rows.Close() - - columns, err := rows.Columns() - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("failed to get columns: %w", err) - } - - var results []map[string]interface{} - for rows.Next() { - values := make([]interface{}, len(columns)) - valuePtrs := make([]interface{}, len(columns)) - for i := range values { - valuePtrs[i] = &values[i] - } - - if err := rows.Scan(valuePtrs...); err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("scan error: %w", err) - } - - row := make(map[string]interface{}) - for i, col := range columns { - val := values[i] - if b, ok := val.([]byte); ok { - row[col] = string(b) - } else { - row[col] = val - } - } - results = append(results, row) - } - - if err := rows.Err(); err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("rows error: %w", err) - } - - jsonData, err := json.MarshalIndent(results, "", " ") - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("json marshal error: %w", err) - } - session.result = append(jsonData, '\n') - session.lastError = "" - } else { - result, err := session.tx.Exec(sqlStmt) - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("execution error: %w", err) - } - - rowsAffected, _ := result.RowsAffected() - lastInsertId, _ := result.LastInsertId() - - resultMap := map[string]interface{}{ - "rows_affected": rowsAffected, - "last_insert_id": lastInsertId, - } - jsonData, _ := json.MarshalIndent(resultMap, "", " ") - session.result = append(jsonData, '\n') - session.lastError = "" - } - - return int64(len(data)), nil - - case "result", "error": - return 0, fmt.Errorf("%s is read-only", operation) - - case "": - return 0, fmt.Errorf("cannot write to directory: %s", path) - - default: - return 0, fmt.Errorf("unknown session file: %s", operation) - } - } - - // Table-level files (no session) - if sid == "" { - switch operation { - case "": - return 0, fmt.Errorf("cannot write to directory: %s", path) - case "ctl", "schema", "count": - return 0, fmt.Errorf("%s is read-only", operation) - default: - return 0, fmt.Errorf("unknown table-level file: %s", operation) - } - } - - // Session-level files (table-bound sessions) - session := fs.sessionManager.GetSession(dbName, tableName, sid) - if session == nil { - return 0, fmt.Errorf("session not found: %s", sid) - } - - session.mu.Lock() - defer session.UnlockWithTouch() - - switch operation { - case "ctl": - // Writing "close" to ctl closes the session - cmd := strings.TrimSpace(string(data)) - if cmd == "close" { - session.mu.Unlock() // Unlock before closing - err := fs.sessionManager.CloseSession(dbName, tableName, sid) - session.mu.Lock() // Re-lock for deferred unlock - if err != nil { - return 0, err - } - return int64(len(data)), nil - } - return 0, fmt.Errorf("unknown ctl command: %s", cmd) - - case "query": - // Execute SQL query and store result - sqlStmt := strings.TrimSpace(string(data)) - if sqlStmt == "" { - session.lastError = "empty SQL statement" - return 0, fmt.Errorf("empty SQL statement") - } - - // Determine if this is a SELECT query - upperSQL := strings.ToUpper(sqlStmt) - isSelect := strings.HasPrefix(upperSQL, "SELECT") || - strings.HasPrefix(upperSQL, "SHOW") || - strings.HasPrefix(upperSQL, "DESCRIBE") || - strings.HasPrefix(upperSQL, "EXPLAIN") - - if isSelect { - // Execute SELECT query - rows, err := session.tx.Query(sqlStmt) - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("query error: %w", err) - } - defer rows.Close() - - // Get column names - columns, err := rows.Columns() - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("failed to get columns: %w", err) - } - - // Read all results - var results []map[string]interface{} - for rows.Next() { - values := make([]interface{}, len(columns)) - valuePtrs := make([]interface{}, len(columns)) - for i := range values { - valuePtrs[i] = &values[i] - } - - if err := rows.Scan(valuePtrs...); err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("scan error: %w", err) - } - - row := make(map[string]interface{}) - for i, col := range columns { - val := values[i] - if b, ok := val.([]byte); ok { - row[col] = string(b) - } else { - row[col] = val - } - } - results = append(results, row) - } - - if err := rows.Err(); err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("rows error: %w", err) - } - - // Store results as JSON - jsonData, err := json.MarshalIndent(results, "", " ") - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("json marshal error: %w", err) - } - session.result = append(jsonData, '\n') - session.lastError = "" - } else { - // Execute DML statement (INSERT, UPDATE, DELETE, etc.) - result, err := session.tx.Exec(sqlStmt) - if err != nil { - session.lastError = err.Error() - session.result = nil - return 0, fmt.Errorf("execution error: %w", err) - } - - rowsAffected, _ := result.RowsAffected() - lastInsertId, _ := result.LastInsertId() - - // Store result as JSON - resultMap := map[string]interface{}{ - "rows_affected": rowsAffected, - "last_insert_id": lastInsertId, - } - jsonData, _ := json.MarshalIndent(resultMap, "", " ") - session.result = append(jsonData, '\n') - session.lastError = "" - } - - return int64(len(data)), nil - - case "data": - // Insert JSON data - columns, err := fs.plugin.backend.GetTableColumns(fs.plugin.db, dbName, tableName) - if err != nil { - session.lastError = err.Error() - return 0, fmt.Errorf("failed to get table columns: %w", err) - } - - if len(columns) == 0 { - session.lastError = "no columns found for table" - return 0, fmt.Errorf("no columns found for table %s", tableName) - } - - columnNames := make([]string, len(columns)) - for i, col := range columns { - columnNames[i] = col.Name - } - - // Parse JSON (support single object, array, or NDJSON) - var records []map[string]interface{} - dataStr := string(data) - lines := strings.Split(dataStr, "\n") - - // Check for NDJSON mode - nonEmptyLines := 0 - firstNonEmptyIdx := -1 - for i, line := range lines { - if strings.TrimSpace(line) != "" { - nonEmptyLines++ - if firstNonEmptyIdx == -1 { - firstNonEmptyIdx = i - } - } - } - - isStreamMode := false - if nonEmptyLines > 1 && firstNonEmptyIdx >= 0 { - var testObj map[string]interface{} - firstLine := strings.TrimSpace(lines[firstNonEmptyIdx]) - if err := json.Unmarshal([]byte(firstLine), &testObj); err == nil { - isStreamMode = true - } - } - - if isStreamMode { - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var record map[string]interface{} - if err := json.Unmarshal([]byte(line), &record); err != nil { - continue - } - records = append(records, record) - } - } else { - var jsonData interface{} - if err := json.Unmarshal(data, &jsonData); err != nil { - session.lastError = err.Error() - return 0, fmt.Errorf("invalid JSON: %w", err) - } - - switch v := jsonData.(type) { - case map[string]interface{}: - records = append(records, v) - case []interface{}: - for i, item := range v { - if record, ok := item.(map[string]interface{}); ok { - records = append(records, record) - } else { - session.lastError = fmt.Sprintf("element at index %d is not a JSON object", i) - return 0, fmt.Errorf("element at index %d is not a JSON object", i) - } - } - default: - session.lastError = "JSON must be an object or array of objects" - return 0, fmt.Errorf("JSON must be an object or array of objects") - } - } - - if len(records) == 0 { - session.lastError = "no records to insert" - return 0, fmt.Errorf("no records to insert") - } - - // Execute inserts in transaction - insertedCount := 0 - for idx, record := range records { - values := make([]interface{}, len(columnNames)) - for i, colName := range columnNames { - if val, ok := record[colName]; ok { - values[i] = val - } else { - values[i] = nil - } - } - - placeholders := make([]string, len(columnNames)) - for i := range placeholders { - placeholders[i] = "?" - } - - insertSQL := fmt.Sprintf("INSERT INTO %s.%s (%s) VALUES (%s)", - dbName, tableName, - strings.Join(columnNames, ", "), - strings.Join(placeholders, ", ")) - - if _, err := session.tx.Exec(insertSQL, values...); err != nil { - session.lastError = fmt.Sprintf("insert error at record %d: %v", idx+1, err) - session.result = nil - return 0, fmt.Errorf("insert error at record %d: %w", idx+1, err) - } - insertedCount++ - } - - // Store result as JSON - resultMap := map[string]interface{}{ - "inserted_count": insertedCount, - } - jsonData, _ := json.MarshalIndent(resultMap, "", " ") - session.result = append(jsonData, '\n') - session.lastError = "" - - return int64(len(data)), nil - - case "result", "error": - return 0, fmt.Errorf("%s is read-only", operation) - - case "": - return 0, fmt.Errorf("cannot write to directory: %s", path) - - default: - return 0, fmt.Errorf("unknown session file: %s", operation) - } -} - -func (fs *sqlfs2FS) Create(path string) error { - return fmt.Errorf("operation not supported: create") -} - -func (fs *sqlfs2FS) Mkdir(path string, perm uint32) error { - return fmt.Errorf("operation not supported: mkdir") -} - -func (fs *sqlfs2FS) Remove(path string) error { - return fmt.Errorf("operation not supported: remove") -} - -func (fs *sqlfs2FS) RemoveAll(path string) error { - dbName, tableName, sid, operation, err := fs.parsePath(path) - if err != nil { - return err - } - - // Support removing root-level session - // Path should be / - if dbName == "" && tableName == "" && sid != "" && operation == "" { - return fs.sessionManager.CloseSession("", "", sid) - } - - // Support removing database-level session - // Path should be /dbName/ - if dbName != "" && tableName == "" && sid != "" && operation == "" { - return fs.sessionManager.CloseSession(dbName, "", sid) - } - - // Support removing database (DROP DATABASE) - // Path should be /dbName - if dbName != "" && tableName == "" && sid == "" && operation == "" { - // Execute DROP DATABASE - sqlStmt := fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName) - _, err := fs.plugin.db.Exec(sqlStmt) - if err != nil { - return fmt.Errorf("failed to drop database: %w", err) - } - - log.Infof("[sqlfs2] Dropped database: %s", dbName) - return nil - } - - // Support removing tables (DROP TABLE) - // Path should be /dbName/tableName - if dbName != "" && tableName != "" && sid == "" && operation == "" { - // Switch to database if needed - if err := fs.plugin.backend.SwitchDatabase(fs.plugin.db, dbName); err != nil { - return err - } - - // Execute DROP TABLE - sqlStmt := fmt.Sprintf("DROP TABLE IF EXISTS %s.%s", dbName, tableName) - _, err := fs.plugin.db.Exec(sqlStmt) - if err != nil { - return fmt.Errorf("failed to drop table: %w", err) - } - - log.Infof("[sqlfs2] Dropped table: %s.%s", dbName, tableName) - return nil - } - - // Support removing session directory - // Path should be /dbName/tableName/ - if dbName != "" && tableName != "" && sid != "" && operation == "" { - return fs.sessionManager.CloseSession(dbName, tableName, sid) - } - - return fmt.Errorf("operation not supported: can only remove databases, tables, or sessions") -} - -func (fs *sqlfs2FS) ReadDir(path string) ([]filesystem.FileInfo, error) { - dbName, tableName, sid, operation, err := fs.parsePath(path) - if err != nil { - return nil, err - } - - now := time.Now() - - // Root directory: list ctl, databases, and root-level sessions - if dbName == "" && tableName == "" && sid == "" && operation == "" { - entries := []filesystem.FileInfo{ - { - Name: "ctl", - Size: 0, - Mode: 0444, // read-only (reading creates session) - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "ctl"}, - }, - } - - // Add root-level sessions - sids := fs.sessionManager.ListSessions("", "") - for _, s := range sids { - entries = append(entries, filesystem.FileInfo{ - Name: s, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "session"}, - }) - } - - // Add databases - dbNames, err := fs.plugin.backend.ListDatabases(fs.plugin.db) - if err != nil { - return nil, err - } - for _, name := range dbNames { - entries = append(entries, filesystem.FileInfo{ - Name: name, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "database"}, - }) - } - return entries, nil - } - - // Root-level session directory - if dbName == "" && tableName == "" && sid != "" && operation == "" { - session := fs.sessionManager.GetSession("", "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - return []filesystem.FileInfo{ - { - Name: "ctl", - Size: 0, - Mode: 0222, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "session-ctl"}, - }, - { - Name: "query", - Size: 0, - Mode: 0222, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "query"}, - }, - { - Name: "result", - Size: 0, - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "result"}, - }, - { - Name: "error", - Size: 0, - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "error"}, - }, - }, nil - } - - // Database level: list ctl, tables, and database-level sessions - if dbName != "" && tableName == "" && sid == "" && operation == "" { - entries := []filesystem.FileInfo{ - { - Name: "ctl", - Size: 0, - Mode: 0444, // read-only (reading creates session) - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "ctl"}, - }, - } - - // Add database-level sessions - sids := fs.sessionManager.ListSessions(dbName, "") - for _, s := range sids { - entries = append(entries, filesystem.FileInfo{ - Name: s, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "session"}, - }) - } - - // Add tables - tableNames, err := fs.plugin.backend.ListTables(fs.plugin.db, dbName) - if err != nil { - return nil, err - } - for _, name := range tableNames { - entries = append(entries, filesystem.FileInfo{ - Name: name, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "table"}, - }) - } - return entries, nil - } - - // Database-level session directory - if dbName != "" && tableName == "" && sid != "" && operation == "" { - session := fs.sessionManager.GetSession(dbName, "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - return []filesystem.FileInfo{ - { - Name: "ctl", - Size: 0, - Mode: 0222, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "session-ctl"}, - }, - { - Name: "query", - Size: 0, - Mode: 0222, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "query"}, - }, - { - Name: "result", - Size: 0, - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "result"}, - }, - { - Name: "error", - Size: 0, - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "error"}, - }, - }, nil - } - - // Table level: list ctl, schema, count, and session directories - if sid == "" && operation == "" { - // Check if table exists - exists, err := fs.tableExists(dbName, tableName) - if err != nil { - return nil, fmt.Errorf("failed to check table existence: %w", err) - } - if !exists { - return nil, fmt.Errorf("table '%s.%s' does not exist", dbName, tableName) - } - - entries := []filesystem.FileInfo{ - { - Name: "ctl", - Size: 0, - Mode: 0444, // read-only (reading creates session) - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "ctl"}, - }, - { - Name: "schema", - Size: 0, - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "schema"}, - }, - { - Name: "count", - Size: 0, - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "count"}, - }, - } - - // Add active session directories - sids := fs.sessionManager.ListSessions(dbName, tableName) - for _, s := range sids { - entries = append(entries, filesystem.FileInfo{ - Name: s, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "session"}, - }) - } - - return entries, nil - } - - // Session directory: list session files - if sid != "" && operation == "" { - session := fs.sessionManager.GetSession(dbName, tableName, sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - return []filesystem.FileInfo{ - { - Name: "ctl", - Size: 0, - Mode: 0222, // write-only (writing closes session) - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "session-ctl"}, - }, - { - Name: "query", - Size: 0, - Mode: 0222, // write-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "query"}, - }, - { - Name: "result", - Size: 0, - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "result"}, - }, - { - Name: "data", - Size: 0, - Mode: 0222, // write-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "data"}, - }, - { - Name: "error", - Size: 0, - Mode: 0444, // read-only - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "error"}, - }, - }, nil - } - - return nil, fmt.Errorf("not a directory: %s", path) -} - -func (fs *sqlfs2FS) Stat(path string) (*filesystem.FileInfo, error) { - dbName, tableName, sid, operation, err := fs.parsePath(path) - if err != nil { - return nil, err - } - - now := time.Now() - - // Root directory - if dbName == "" && tableName == "" && sid == "" && operation == "" { - return &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName}, - }, nil - } - - // Root-level ctl file - if dbName == "" && tableName == "" && sid == "" && operation == "ctl" { - return &filesystem.FileInfo{ - Name: "ctl", - Size: 0, - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "ctl"}, - }, nil - } - - // Root-level session directory - if dbName == "" && tableName == "" && sid != "" && operation == "" { - session := fs.sessionManager.GetSession("", "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - return &filesystem.FileInfo{ - Name: sid, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "session"}, - }, nil - } - - // Root-level session files - if dbName == "" && tableName == "" && sid != "" && operation != "" { - session := fs.sessionManager.GetSession("", "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - var mode uint32 - switch operation { - case "ctl", "query": - mode = 0222 // write-only - case "result", "error": - mode = 0444 // read-only - default: - return nil, fmt.Errorf("unknown session file: %s", operation) - } - return &filesystem.FileInfo{ - Name: operation, - Size: 0, - Mode: mode, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: operation}, - }, nil - } - - // Database directory - if dbName != "" && tableName == "" && sid == "" && operation == "" { - return &filesystem.FileInfo{ - Name: dbName, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "database"}, - }, nil - } - - // Database-level ctl file - if dbName != "" && tableName == "" && sid == "" && operation == "ctl" { - return &filesystem.FileInfo{ - Name: "ctl", - Size: 0, - Mode: 0444, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: "ctl"}, - }, nil - } - - // Database-level session directory - if dbName != "" && tableName == "" && sid != "" && operation == "" { - session := fs.sessionManager.GetSession(dbName, "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - return &filesystem.FileInfo{ - Name: sid, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "session"}, - }, nil - } - - // Database-level session files - if dbName != "" && tableName == "" && sid != "" && operation != "" { - session := fs.sessionManager.GetSession(dbName, "", sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - var mode uint32 - switch operation { - case "ctl", "query": - mode = 0222 // write-only - case "result", "error": - mode = 0444 // read-only - default: - return nil, fmt.Errorf("unknown session file: %s", operation) - } - return &filesystem.FileInfo{ - Name: operation, - Size: 0, - Mode: mode, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: operation}, - }, nil - } - - // Table directory - if sid == "" && operation == "" { - // Check if table exists - exists, err := fs.tableExists(dbName, tableName) - if err != nil { - return nil, fmt.Errorf("failed to check table existence: %w", err) - } - if !exists { - return nil, fmt.Errorf("table '%s.%s' does not exist", dbName, tableName) - } - - return &filesystem.FileInfo{ - Name: tableName, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "table"}, - }, nil - } - - // Table-level files (ctl, schema, count) - if sid == "" && operation != "" { - mode := uint32(0444) // read-only by default - return &filesystem.FileInfo{ - Name: operation, - Size: 0, - Mode: mode, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: operation}, - }, nil - } - - // Session directory - if sid != "" && operation == "" { - session := fs.sessionManager.GetSession(dbName, tableName, sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - return &filesystem.FileInfo{ - Name: sid, - Size: 0, - Mode: 0755, - ModTime: now, - IsDir: true, - Meta: filesystem.MetaData{Name: PluginName, Type: "session"}, - }, nil - } - - // Session-level files - if sid != "" && operation != "" { - session := fs.sessionManager.GetSession(dbName, tableName, sid) - if session == nil { - return nil, fmt.Errorf("session not found: %s", sid) - } - - var mode uint32 - switch operation { - case "ctl", "query", "data": - mode = 0222 // write-only - case "result", "error": - mode = 0444 // read-only - default: - return nil, fmt.Errorf("unknown session file: %s", operation) - } - - return &filesystem.FileInfo{ - Name: operation, - Size: 0, - Mode: mode, - ModTime: now, - IsDir: false, - Meta: filesystem.MetaData{Name: PluginName, Type: operation}, - }, nil - } - - return nil, fmt.Errorf("invalid path: %s", path) -} - -func (fs *sqlfs2FS) Rename(oldPath, newPath string) error { - return fmt.Errorf("operation not supported: rename") -} - -func (fs *sqlfs2FS) Chmod(path string, mode uint32) error { - return fmt.Errorf("operation not supported: chmod") -} - -func (fs *sqlfs2FS) Open(path string) (io.ReadCloser, error) { - data, err := fs.Read(path, 0, -1) - if err != nil && err != io.EOF { - return nil, err - } - return io.NopCloser(bytes.NewReader(data)), nil -} - -func (fs *sqlfs2FS) OpenWrite(path string) (io.WriteCloser, error) { - return filesystem.NewBufferedWriter(path, fs.Write), nil -} - -func getReadme() string { - return `SQLFS2 Plugin - Plan 9 Style SQL Interface - -This plugin provides a Plan 9 style SQL interface through file system operations. -Each SQL session is represented as a directory with control files. - -DIRECTORY STRUCTURE: - /sqlfs2/// - ctl # Read to create new session, returns session ID - schema # Read-only: table structure (CREATE TABLE) - count # Read-only: row count - / # Session directory (numeric ID) - ctl # Write "close" to close session - query # Write SQL to execute - result # Read query results (JSON) - data # Write JSON to insert - error # Read error messages - -BASIC WORKFLOW: - - # Create a session - sid=$(cat /sqlfs2/mydb/users/ctl) - - # Execute query - echo 'SELECT * FROM users' > /sqlfs2/mydb/users/$sid/query - - # Read results - cat /sqlfs2/mydb/users/$sid/result - - # Close session - echo close > /sqlfs2/mydb/users/$sid/ctl - # or: rm -rf /sqlfs2/mydb/users/$sid - -CONFIGURATION: - - SQLite Backend: - [plugins.sqlfs2] - enabled = true - path = "/sqlfs2" - - [plugins.sqlfs2.config] - backend = "sqlite" - db_path = "sqlfs2.db" - session_timeout = "10m" # Optional: auto-cleanup idle sessions - - MySQL Backend: - [plugins.sqlfs2] - enabled = true - path = "/sqlfs2" - - [plugins.sqlfs2.config] - backend = "mysql" - host = "localhost" - port = "3306" - user = "root" - password = "password" - database = "mydb" - - TiDB Backend: - [plugins.sqlfs2] - enabled = true - path = "/sqlfs2" - - [plugins.sqlfs2.config] - backend = "tidb" - host = "127.0.0.1" - port = "4000" - user = "root" - database = "test" - enable_tls = true # For TiDB Cloud - -USAGE EXAMPLES: - - # View table schema - cat /sqlfs2/mydb/users/schema - - # Get row count - cat /sqlfs2/mydb/users/count - - # Create session and query - sid=$(cat /sqlfs2/mydb/users/ctl) - echo 'SELECT * FROM users WHERE age > 18' > /sqlfs2/mydb/users/$sid/query - cat /sqlfs2/mydb/users/$sid/result - - # Execute INSERT/UPDATE/DELETE via query file - echo 'INSERT INTO users (name, age) VALUES ("Alice", 25)' > /sqlfs2/mydb/users/$sid/query - cat /sqlfs2/mydb/users/$sid/result # Shows rows_affected - - # Insert JSON data (single object) - echo '{"name": "Bob", "age": 30}' > /sqlfs2/mydb/users/$sid/data - - # Insert JSON array (multiple records) - echo '[{"name": "Carol"}, {"name": "Dave"}]' > /sqlfs2/mydb/users/$sid/data - - # Insert NDJSON stream - cat < /sqlfs2/mydb/users/$sid/data - {"name": "Eve", "age": 28} - {"name": "Frank", "age": 35} - EOF - - # Check for errors - cat /sqlfs2/mydb/users/$sid/error - - # Close session - echo close > /sqlfs2/mydb/users/$sid/ctl - - # List databases - ls /sqlfs2/ - - # List tables - ls /sqlfs2/mydb/ - - # List table files and sessions - ls /sqlfs2/mydb/users/ - -SESSION MANAGEMENT: - - Sessions are created by reading the table-level ctl file. - Each session has its own SQL transaction that is committed - when queries succeed. Sessions can be closed by: - - Writing "close" to the session's ctl file - - Removing the session directory (rm -rf /sqlfs2/db/tbl/$sid) - - Automatic timeout (if session_timeout is configured) - -ADVANTAGES: - - Plan 9 style interface: everything is a file - - Session-based transactions - - JSON output for query results - - Support for SQLite, MySQL, and TiDB backends - - Auto-generate INSERT from JSON documents - - NDJSON streaming for large imports - - Configurable session timeout -` -} - -// Ensure SQLFS2Plugin implements ServicePlugin -var _ plugin.ServicePlugin = (*SQLFS2Plugin)(nil) -var _ filesystem.FileSystem = (*sqlfs2FS)(nil) -var _ filesystem.HandleFS = (*sqlfs2FS)(nil) - -// ============================================================================ -// HandleFS Implementation -// ============================================================================ - -// SQLFileHandle implements FileHandle using a SQL transaction -type SQLFileHandle struct { - id int64 - path string - flags filesystem.OpenFlag - fs *sqlfs2FS - tx *sql.Tx - committed bool - closed bool - mu sync.Mutex - - // Buffer for accumulating writes (for query operations) - writeBuffer bytes.Buffer - // Buffer for read results - readBuffer bytes.Buffer - readPos int64 - - // Parsed path components - dbName string - tableName string - sid string - operation string -} - -// ID returns the unique identifier of this handle -func (h *SQLFileHandle) ID() int64 { - return h.id -} - -// Path returns the file path this handle is associated with -func (h *SQLFileHandle) Path() string { - return h.path -} - -// Flags returns the open flags used when opening this handle -func (h *SQLFileHandle) Flags() filesystem.OpenFlag { - return h.flags -} - -// Read reads up to len(buf) bytes from the current position -func (h *SQLFileHandle) Read(buf []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check read permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_RDONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for reading") - } - - // If read buffer is empty, populate it based on operation type - if h.readBuffer.Len() == 0 && h.readPos == 0 { - if err := h.populateReadBuffer(); err != nil { - return 0, err - } - } - - data := h.readBuffer.Bytes() - if h.readPos >= int64(len(data)) { - return 0, io.EOF - } - - n := copy(buf, data[h.readPos:]) - h.readPos += int64(n) - return n, nil -} - -// populateReadBuffer fills the read buffer based on the operation type -func (h *SQLFileHandle) populateReadBuffer() error { - switch h.operation { - case "ctl": - // Reading ctl creates a new session and returns the session ID - // For root-level ctl (no db, no table) - if h.dbName == "" && h.tableName == "" { - session, err := h.fs.sessionManager.CreateSession(h.fs.plugin.db, "", "") - if err != nil { - return err - } - h.readBuffer.WriteString(fmt.Sprintf("%d\n", session.id)) - return nil - } - - // For table-level ctl - if h.dbName != "" && h.tableName != "" { - // Check if table exists - exists, err := h.fs.tableExists(h.dbName, h.tableName) - if err != nil { - return fmt.Errorf("failed to check table existence: %w", err) - } - if !exists { - return fmt.Errorf("table '%s.%s' does not exist", h.dbName, h.tableName) - } - - // Switch to database if needed - if err := h.fs.plugin.backend.SwitchDatabase(h.fs.plugin.db, h.dbName); err != nil { - return err - } - - // Create new session - session, err := h.fs.sessionManager.CreateSession(h.fs.plugin.db, h.dbName, h.tableName) - if err != nil { - return err - } - h.readBuffer.WriteString(fmt.Sprintf("%d\n", session.id)) - return nil - } - - return fmt.Errorf("invalid path for ctl") - - case "schema": - if h.dbName == "" || h.tableName == "" { - return fmt.Errorf("invalid path for schema") - } - createTableStmt, err := h.fs.plugin.backend.GetTableSchema(h.fs.plugin.db, h.dbName, h.tableName) - if err != nil { - return err - } - h.readBuffer.WriteString(createTableStmt + "\n") - - case "count": - if h.dbName == "" || h.tableName == "" { - return fmt.Errorf("invalid path for count") - } - // Use transaction for count query - sqlStmt := fmt.Sprintf("SELECT COUNT(*) FROM %s.%s", h.dbName, h.tableName) - var count int64 - var err error - if h.tx != nil { - err = h.tx.QueryRow(sqlStmt).Scan(&count) - } else { - err = h.fs.plugin.db.QueryRow(sqlStmt).Scan(&count) - } - if err != nil { - return fmt.Errorf("count query error: %w", err) - } - h.readBuffer.WriteString(fmt.Sprintf("%d\n", count)) - - case "result": - // Result is read from session, but for handle-based access we need to get it from somewhere - // For now, return empty - the session-based Read handles this case - return nil - - case "error": - // Error is read from session, but for handle-based access we need to get it from somewhere - // For now, return empty - the session-based Read handles this case - return nil - - case "query", "data", "execute", "insert_json": - // These are write-only operations, return empty - return nil - - default: - return fmt.Errorf("unknown operation: %s", h.operation) - } - - return nil -} - -// ReadAt reads len(buf) bytes from the specified offset (pread) -func (h *SQLFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check read permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_RDONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for reading") - } - - // If read buffer is empty, populate it - if h.readBuffer.Len() == 0 { - if err := h.populateReadBuffer(); err != nil { - return 0, err - } - } - - data := h.readBuffer.Bytes() - if offset >= int64(len(data)) { - return 0, io.EOF - } - - n := copy(buf, data[offset:]) - return n, nil -} - -// Write writes data at the current position (appends to write buffer) -func (h *SQLFileHandle) Write(data []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check write permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_WRONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for writing") - } - - if h.operation == "schema" || h.operation == "count" { - return 0, fmt.Errorf("%s is read-only", h.operation) - } - - // Append to write buffer - n, err := h.writeBuffer.Write(data) - return n, err -} - -// WriteAt writes data at the specified offset (pwrite) -func (h *SQLFileHandle) WriteAt(data []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Check write permission - accessMode := h.flags & 0x3 - if accessMode != filesystem.O_WRONLY && accessMode != filesystem.O_RDWR { - return 0, fmt.Errorf("handle not opened for writing") - } - - if h.operation == "schema" || h.operation == "count" { - return 0, fmt.Errorf("%s is read-only", h.operation) - } - - // For SQL operations, we don't support random writes - // Just append the data - n, err := h.writeBuffer.Write(data) - return n, err -} - -// Seek moves the read/write position -func (h *SQLFileHandle) Seek(offset int64, whence int) (int64, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return 0, fmt.Errorf("handle closed") - } - - // Only support seek for read operations - data := h.readBuffer.Bytes() - var newPos int64 - - switch whence { - case io.SeekStart: - newPos = offset - case io.SeekCurrent: - newPos = h.readPos + offset - case io.SeekEnd: - newPos = int64(len(data)) + offset - default: - return 0, fmt.Errorf("invalid whence: %d", whence) - } - - if newPos < 0 { - return 0, fmt.Errorf("negative position") - } - - h.readPos = newPos - return h.readPos, nil -} - -// Sync executes the buffered SQL and commits the transaction -func (h *SQLFileHandle) Sync() error { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return fmt.Errorf("handle closed") - } - - if h.committed { - return nil // Already committed - } - - if h.tx == nil { - return nil // No transaction to commit - } - - // Execute any buffered SQL statements - if h.writeBuffer.Len() > 0 { - if err := h.executeBufferedSQL(); err != nil { - return err - } - } - - // Commit the transaction - if err := h.tx.Commit(); err != nil { - return fmt.Errorf("transaction commit failed: %w", err) - } - - h.committed = true - log.Debugf("[sqlfs2] Transaction committed for handle %d", h.id) - return nil -} - -// executeBufferedSQL executes the SQL statements in the write buffer -func (h *SQLFileHandle) executeBufferedSQL() error { - sqlStmt := strings.TrimSpace(h.writeBuffer.String()) - if sqlStmt == "" { - return nil - } - - switch h.operation { - case "query": - // Execute SELECT query in transaction - rows, err := h.tx.Query(sqlStmt) - if err != nil { - return fmt.Errorf("query error: %w", err) - } - defer rows.Close() - - // Get column names - columns, err := rows.Columns() - if err != nil { - return fmt.Errorf("failed to get columns: %w", err) - } - - // Read all results - var results []map[string]interface{} - for rows.Next() { - values := make([]interface{}, len(columns)) - valuePtrs := make([]interface{}, len(columns)) - for i := range values { - valuePtrs[i] = &values[i] - } - - if err := rows.Scan(valuePtrs...); err != nil { - return fmt.Errorf("scan error: %w", err) - } - - row := make(map[string]interface{}) - for i, col := range columns { - val := values[i] - if b, ok := val.([]byte); ok { - row[col] = string(b) - } else { - row[col] = val - } - } - results = append(results, row) - } - - if err := rows.Err(); err != nil { - return fmt.Errorf("rows error: %w", err) - } - - // Store results in read buffer for subsequent reads - jsonData, err := json.MarshalIndent(results, "", " ") - if err != nil { - return fmt.Errorf("json marshal error: %w", err) - } - h.readBuffer.Reset() - h.readBuffer.Write(jsonData) - h.readBuffer.WriteString("\n") - h.readPos = 0 - - case "execute": - // Execute DML statement in transaction - _, err := h.tx.Exec(sqlStmt) - if err != nil { - return fmt.Errorf("execution error: %w", err) - } - - case "insert_json": - // Execute JSON insert in transaction - if err := h.executeInsertJSON(sqlStmt); err != nil { - return err - } - - default: - return fmt.Errorf("unknown operation: %s", h.operation) - } - - // Clear write buffer after execution - h.writeBuffer.Reset() - return nil -} - -// executeInsertJSON handles JSON insert operations within the transaction -func (h *SQLFileHandle) executeInsertJSON(data string) error { - if h.dbName == "" || h.tableName == "" { - return fmt.Errorf("invalid path for insert_json") - } - - // Get table columns - columns, err := h.fs.plugin.backend.GetTableColumns(h.fs.plugin.db, h.dbName, h.tableName) - if err != nil { - return fmt.Errorf("failed to get table columns: %w", err) - } - - if len(columns) == 0 { - return fmt.Errorf("no columns found for table %s", h.tableName) - } - - columnNames := make([]string, len(columns)) - for i, col := range columns { - columnNames[i] = col.Name - } - - // Parse JSON - var records []map[string]interface{} - lines := strings.Split(data, "\n") - - // Check for NDJSON mode - nonEmptyLines := 0 - firstNonEmptyIdx := -1 - for i, line := range lines { - if strings.TrimSpace(line) != "" { - nonEmptyLines++ - if firstNonEmptyIdx == -1 { - firstNonEmptyIdx = i - } - } - } - - isStreamMode := false - if nonEmptyLines > 1 && firstNonEmptyIdx >= 0 { - var testObj map[string]interface{} - firstLine := strings.TrimSpace(lines[firstNonEmptyIdx]) - if err := json.Unmarshal([]byte(firstLine), &testObj); err == nil { - isStreamMode = true - } - } - - if isStreamMode { - for _, line := range lines { - line = strings.TrimSpace(line) - if line == "" { - continue - } - var record map[string]interface{} - if err := json.Unmarshal([]byte(line), &record); err != nil { - continue - } - records = append(records, record) - } - } else { - var jsonData interface{} - if err := json.Unmarshal([]byte(data), &jsonData); err != nil { - return fmt.Errorf("invalid JSON: %w", err) - } - - switch v := jsonData.(type) { - case map[string]interface{}: - records = append(records, v) - case []interface{}: - for i, item := range v { - if record, ok := item.(map[string]interface{}); ok { - records = append(records, record) - } else { - return fmt.Errorf("element at index %d is not a JSON object", i) - } - } - default: - return fmt.Errorf("JSON must be an object or array of objects") - } - } - - // Execute inserts in transaction - for idx, record := range records { - values := make([]interface{}, len(columnNames)) - for i, colName := range columnNames { - if val, ok := record[colName]; ok { - values[i] = val - } else { - values[i] = nil - } - } - - placeholders := make([]string, len(columnNames)) - for i := range placeholders { - placeholders[i] = "?" - } - - insertSQL := fmt.Sprintf("INSERT INTO %s.%s (%s) VALUES (%s)", - h.dbName, h.tableName, - strings.Join(columnNames, ", "), - strings.Join(placeholders, ", ")) - - if _, err := h.tx.Exec(insertSQL, values...); err != nil { - return fmt.Errorf("insert error at record %d: %w", idx+1, err) - } - } - - return nil -} - -// Close closes the handle and rolls back if not committed -func (h *SQLFileHandle) Close() error { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return nil - } - - h.closed = true - - // Rollback if not committed - if h.tx != nil && !h.committed { - if err := h.tx.Rollback(); err != nil && err != sql.ErrTxDone { - log.Warnf("[sqlfs2] Transaction rollback failed for handle %d: %v", h.id, err) - } else { - log.Debugf("[sqlfs2] Transaction rolled back for handle %d", h.id) - } - } - - // Remove from handles map - h.fs.handlesMu.Lock() - delete(h.fs.handles, h.id) - h.fs.handlesMu.Unlock() - - return nil -} - -// Stat returns file information -func (h *SQLFileHandle) Stat() (*filesystem.FileInfo, error) { - h.mu.Lock() - defer h.mu.Unlock() - - if h.closed { - return nil, fmt.Errorf("handle closed") - } - - return h.fs.Stat(h.path) -} - -// OpenHandle opens a file and returns a handle with a new transaction -func (fs *sqlfs2FS) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - dbName, tableName, sid, operation, err := fs.parsePath(path) - if err != nil { - return nil, err - } - - // Only support handle operations on operation files - if operation == "" { - return nil, fmt.Errorf("cannot open handle on directory: %s", path) - } - - // Session-related paths do not support HandleFS mode. - // The session model requires immediate SQL execution on write and reading results - // from the session state, which is incompatible with HandleFS's buffered I/O model. - // Return ErrNotSupported so FUSE falls back to using Read/Write methods directly. - if sid != "" { - log.Debugf("[sqlfs2] HandleFS not supported for session path: %s (use Read/Write instead)", path) - return nil, filesystem.ErrNotSupported - } - - // Check if table exists for table-level operations - if tableName != "" { - exists, err := fs.tableExists(dbName, tableName) - if err != nil { - return nil, fmt.Errorf("failed to check table existence: %w", err) - } - if !exists { - return nil, fmt.Errorf("table '%s.%s' does not exist", dbName, tableName) - } - } - - // Switch to database if needed - if err := fs.plugin.backend.SwitchDatabase(fs.plugin.db, dbName); err != nil { - return nil, err - } - - // Start a new transaction - tx, err := fs.plugin.db.Begin() - if err != nil { - return nil, fmt.Errorf("failed to begin transaction: %w", err) - } - - // Create handle with auto-incremented ID - fs.handlesMu.Lock() - handleID := fs.nextHandleID - fs.nextHandleID++ - - handle := &SQLFileHandle{ - id: handleID, - path: path, - flags: flags, - fs: fs, - tx: tx, - dbName: dbName, - tableName: tableName, - sid: sid, - operation: operation, - } - - fs.handles[handleID] = handle - fs.handlesMu.Unlock() - - log.Debugf("[sqlfs2] Opened handle %d for %s (transaction started)", handleID, path) - return handle, nil -} - -// GetHandle retrieves an existing handle by its ID -func (fs *sqlfs2FS) GetHandle(id int64) (filesystem.FileHandle, error) { - fs.handlesMu.RLock() - defer fs.handlesMu.RUnlock() - - handle, exists := fs.handles[id] - if !exists { - return nil, filesystem.ErrNotFound - } - - return handle, nil -} - -// CloseHandle closes a handle by its ID -func (fs *sqlfs2FS) CloseHandle(id int64) error { - fs.handlesMu.RLock() - handle, exists := fs.handles[id] - fs.handlesMu.RUnlock() - - if !exists { - return filesystem.ErrNotFound - } - - return handle.Close() -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/streamfs/README.md b/third_party/agfs/agfs-server/pkg/plugins/streamfs/README.md deleted file mode 100644 index 42dd47336..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/streamfs/README.md +++ /dev/null @@ -1,141 +0,0 @@ -StreamFS Plugin - Streaming File System - -This plugin provides streaming files that support multiple concurrent readers and writers -with real-time data fanout and ring buffer for late joiners. - -DYNAMIC MOUNTING WITH AGFS SHELL: - - Interactive shell - Default settings: - agfs:/> mount streamfs /stream - agfs:/> mount streamfs /live - - Interactive shell - Custom buffer sizes: - agfs:/> mount streamfs /stream channel_buffer_size=512KB ring_buffer_size=1MB - agfs:/> mount streamfs /hq channel_buffer_size=8MB ring_buffer_size=16MB - agfs:/> mount streamfs /lowlatency channel_buffer_size=256KB ring_buffer_size=512KB - - Direct command - Default: - uv run agfs mount streamfs /stream - - Direct command - Custom settings: - uv run agfs mount streamfs /video channel_buffer_size=4MB ring_buffer_size=8MB - uv run agfs mount streamfs /live channel_buffer_size=512KB ring_buffer_size=1MB - -CONFIGURATION PARAMETERS: - - Optional: - - channel_buffer_size: Buffer per reader (default: "6MB") - Supports units: KB, MB, GB or raw bytes (e.g., "512KB", "4MB", 524288) - Controls how much data each reader can buffer before dropping chunks - - - ring_buffer_size: Historical data buffer (default: "6MB") - Supports units: KB, MB, GB or raw bytes (e.g., "1MB", "8MB", 1048576) - Stores recent data for late-joining readers - - Configuration examples by use case: - # Live streaming (low latency) - agfs:/> mount streamfs /live channel_buffer_size=256KB ring_buffer_size=512KB - - # VOD/Recording (smooth playback) - agfs:/> mount streamfs /vod channel_buffer_size=8MB ring_buffer_size=16MB - - # Interactive streaming - agfs:/> mount streamfs /interactive channel_buffer_size=512KB ring_buffer_size=1MB - - # High bitrate video - agfs:/> mount streamfs /hd channel_buffer_size=16MB ring_buffer_size=32MB - -FEATURES: - - Multiple writers can append data to a stream concurrently - - Multiple readers can consume from the stream independently (fanout/broadcast) - - Ring buffer (1000 chunks) stores recent data for late-joining readers - - Persistent streaming: readers wait indefinitely for new data (no timeout disconnect) - - HTTP chunked transfer with automatic flow control - - Memory-based storage with configurable channel buffer per reader - -ARCHITECTURE: - - Each stream maintains a ring buffer of recent chunks (default: last 1000 chunks) - - New readers automatically receive all available historical data from ring buffer - - Writers fanout data to all active readers via buffered channels - - Readers wait indefinitely for new data (30s check interval, but never disconnect) - - Slow readers may drop chunks if their channel buffer fills up - -COMMAND REFERENCE: - - Write (Producer): - cat file | agfs write --stream /streamfs/stream - echo "data" | agfs write /streamfs/stream - - Read (Consumer): - agfs cat --stream /streamfs/stream - agfs cat --stream /streamfs/stream > output.dat - agfs cat --stream /streamfs/stream | ffplay - - - Manage: - agfs ls /streamfs - agfs stat /streamfs/stream - agfs rm /streamfs/stream - -CONFIGURATION: - - [plugins.streamfs] - enabled = true - path = "/streamfs" - - [plugins.streamfs.config] - # Channel buffer size per reader (supports units: KB, MB, GB or raw bytes) - # Controls how much data each reader can buffer before dropping chunks - # For live streaming: 256KB - 512KB (low latency) - # For VOD/recording: 4MB - 8MB (smooth playback) - # Default: 6MB - # Examples: "512KB", "1MB", "6MB", or 524288 (bytes) - channel_buffer_size = "512KB" - - # Ring buffer size for historical data (supports units: KB, MB, GB or raw bytes) - # Stores recent data for late-joining readers - # For live streaming: 512KB - 1MB (low latency, less memory) - # For VOD: 4MB - 8MB (more history for seekable playback) - # Default: 6MB - # Examples: "1MB", "4MB", or 1048576 (bytes) - ring_buffer_size = "1MB" - -IMPORTANT NOTES: - - - Streams are in-memory only (not persistent across restarts) - - Ring buffer stores recent data (configurable, default 6MB) - - Late-joining readers receive historical data from ring buffer - - Readers never timeout - they wait indefinitely for new data - - Writer chunk size: 64KB (configured in CLI write --stream) - - Channel buffer: configurable per reader (default 6MB) - - Slow readers may drop chunks if they can't keep up - - MUST use --stream flag for reading streams (cat --stream) - - Regular cat without --stream will fail with error - -PERFORMANCE TIPS: - - - For live streaming: Use smaller buffers (256KB-512KB) to reduce latency - - For VOD/recording: Use larger buffers (4MB-8MB) for smoother playback - - For video streaming: Start writer first to fill ring buffer - - Increase channel_buffer_size for high-bitrate streams - - Decrease buffer sizes for interactive/live use cases - - Monitor dropped chunks in logs (indicates slow readers) - - Example low-latency config: channel=256KB, ring=512KB - - Example high-throughput config: channel=8MB, ring=16MB - -TROUBLESHOOTING: - - - Error "use stream mode": Use 'cat --stream' instead of 'cat' - - Reader disconnects: Check if writer finished (readers wait indefinitely otherwise) - - High memory usage: Reduce channel_buffer_size or limit concurrent readers - -ARCHITECTURE DETAILS: - - - StreamFS implements filesystem.Streamer interface - - Each reader gets a filesystem.StreamReader with independent position - - Ring buffer enables time-shifting and late joining - - Fanout is non-blocking: slow readers drop chunks, fast readers proceed - - Graceful shutdown: closing stream sends EOF to all readers - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/streamfs/streamfs.go b/third_party/agfs/agfs-server/pkg/plugins/streamfs/streamfs.go deleted file mode 100644 index 5e5d2325a..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/streamfs/streamfs.go +++ /dev/null @@ -1,1249 +0,0 @@ -package streamfs - -import ( - "bytes" - "fmt" - "io" - "strconv" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "streamfs" // Name of this plugin -) - -// parseSize parses a size string like "512KB", "1MB", "100MB" and returns bytes -func parseSize(s string) (int64, error) { - s = strings.TrimSpace(strings.ToUpper(s)) - - // Handle pure numbers (bytes) - if val, err := strconv.ParseInt(s, 10, 64); err == nil { - return val, nil - } - - // Parse with unit suffix - units := map[string]int64{ - "B": 1, - "KB": 1024, - "MB": 1024 * 1024, - "GB": 1024 * 1024 * 1024, - } - - for suffix, multiplier := range units { - if strings.HasSuffix(s, suffix) { - numStr := strings.TrimSuffix(s, suffix) - numStr = strings.TrimSpace(numStr) - - // Try parsing as float first (for "1.5MB") - if val, err := strconv.ParseFloat(numStr, 64); err == nil { - return int64(val * float64(multiplier)), nil - } - } - } - - return 0, fmt.Errorf("invalid size format: %s (expected format: 512KB, 1MB, etc)", s) -} - -// formatSize formats bytes into human-readable format -func formatSize(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%dB", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - units := []string{"KB", "MB", "GB", "TB"} - if exp >= len(units) { - exp = len(units) - 1 - } - return fmt.Sprintf("%.1f%s", float64(bytes)/float64(div), units[exp]) -} - -// Reader represents a single reader with its channel and metadata -type Reader struct { - id string - ch chan []byte - registered time.Time - droppedCount int64 // Number of chunks dropped due to slow consumption - readIndex int64 // Index of next chunk to read from ringBuffer (int64 to prevent overflow) -} - -// streamReader wraps a registered reader and implements filesystem.StreamReader -type streamReader struct { - sf *StreamFile - readerID string - ch <-chan []byte -} - -// ReadChunk implements filesystem.StreamReader -func (sr *streamReader) ReadChunk(timeout time.Duration) ([]byte, bool, error) { - return sr.sf.ReadChunk(sr.readerID, sr.ch, timeout) -} - -// Close implements filesystem.StreamReader -func (sr *streamReader) Close() error { - sr.sf.UnregisterReader(sr.readerID) - return nil -} - -// StreamFile represents a streaming file that supports multiple readers and writers -type StreamFile struct { - name string - mu sync.RWMutex - offset int64 // Total bytes written - closed bool // Whether the stream is closed - modTime time.Time // Last modification time - readers map[string]*Reader // All registered readers - nextReaderID int // Auto-increment reader ID - channelBuffer int // Buffer size for each reader channel - - // Ring buffer for storing recent chunks (even when no readers) - ringBuffer [][]byte // Circular buffer for recent chunks - ringSize int // Max number of chunks to keep - writeIndex int64 // Current write position in ring buffer (int64 to prevent overflow) - totalChunks int64 // Total chunks written (for readIndex tracking) -} - -// NewStreamFile creates a new stream file -func NewStreamFile(name string, channelBuffer int, ringSize int) *StreamFile { - if channelBuffer <= 0 { - channelBuffer = 100 // Default buffer size - } - if ringSize <= 0 { - ringSize = 100 // Default ring buffer size - } - sf := &StreamFile{ - name: name, - modTime: time.Now(), - readers: make(map[string]*Reader), - nextReaderID: 0, - channelBuffer: channelBuffer, - ringBuffer: make([][]byte, ringSize), - ringSize: ringSize, - writeIndex: 0, - totalChunks: 0, - } - return sf -} - -// RegisterReader registers a new reader and returns reader ID and channel -// New readers will receive ALL available historical data from ring buffer -func (sf *StreamFile) RegisterReader() (string, <-chan []byte) { - sf.mu.Lock() - defer sf.mu.Unlock() - - readerID := fmt.Sprintf("reader_%d_%d", sf.nextReaderID, time.Now().UnixNano()) - sf.nextReaderID++ - - // Calculate oldest available chunk in ring buffer - historyStart := sf.totalChunks - int64(sf.ringSize) - if historyStart < 0 { - historyStart = 0 - } - - // New readers start from the beginning of available history - reader := &Reader{ - id: readerID, - ch: make(chan []byte, sf.channelBuffer), - registered: time.Now(), - droppedCount: 0, - readIndex: historyStart, // Start from oldest available data - } - sf.readers[readerID] = reader - - log.Infof("[streamfs] Registered reader %s for stream %s (total readers: %d, starting at chunk %d, current chunk: %d)", - readerID, sf.name, len(sf.readers), reader.readIndex, sf.totalChunks) - - // Send any available historical data from ring buffer - go sf.sendHistoricalData(reader) - - return readerID, reader.ch -} - -// sendHistoricalData sends historical chunks from ring buffer to a new reader -func (sf *StreamFile) sendHistoricalData(reader *Reader) { - sf.mu.RLock() - defer sf.mu.RUnlock() - - // Calculate how many historical chunks are available - historyStart := sf.totalChunks - int64(sf.ringSize) - if historyStart < 0 { - historyStart = 0 - } - - // If reader wants to start from the beginning and we have history - if reader.readIndex < sf.totalChunks && sf.totalChunks > 0 { - log.Debugf("[streamfs] Sending historical data to reader %s (from chunk %d to %d)", - reader.id, historyStart, sf.totalChunks) - - // Send available historical chunks - for i := historyStart; i < sf.totalChunks; i++ { - ringIdx := int(i % int64(sf.ringSize)) - if sf.ringBuffer[ringIdx] != nil { - select { - case reader.ch <- sf.ringBuffer[ringIdx]: - // Sent successfully - default: - // Channel full, will catch up with live data - log.Warnf("[streamfs] Reader %s channel full during historical data send", reader.id) - return - } - } - } - } -} - -// UnregisterReader unregisters a reader and closes its channel -func (sf *StreamFile) UnregisterReader(readerID string) { - sf.mu.Lock() - defer sf.mu.Unlock() - - if reader, exists := sf.readers[readerID]; exists { - close(reader.ch) - delete(sf.readers, readerID) - log.Infof("[streamfs] Unregistered reader %s for stream %s (dropped: %d chunks, total readers: %d)", - readerID, sf.name, reader.droppedCount, len(sf.readers)) - } -} - -// Write appends data to the stream and fanout to all readers -func (sf *StreamFile) Write(data []byte) error { - sf.mu.Lock() - - if sf.closed { - sf.mu.Unlock() - return fmt.Errorf("stream is closed") - } - - // Copy data to avoid external modification - chunk := make([]byte, len(data)) - copy(chunk, data) - - sf.offset += int64(len(data)) - sf.modTime = time.Now() - - // Store in ring buffer (always, even if no readers) - ringIdx := int(sf.writeIndex % int64(sf.ringSize)) - sf.ringBuffer[ringIdx] = chunk - sf.writeIndex++ - sf.totalChunks++ - - // Take a snapshot of all reader channels to avoid holding lock during send - readerSnapshot := make([]*Reader, 0, len(sf.readers)) - for _, reader := range sf.readers { - readerSnapshot = append(readerSnapshot, reader) - } - - sf.mu.Unlock() - - // Fanout to all readers (non-blocking) - successCount := 0 - dropCount := 0 - for _, reader := range readerSnapshot { - select { - case reader.ch <- chunk: - successCount++ - default: - // Channel is full - slow consumer, drop the chunk - reader.droppedCount++ - dropCount++ - log.Warnf("[streamfs] Reader %s is slow, dropped chunk (total dropped: %d)", reader.id, reader.droppedCount) - } - } - - if len(readerSnapshot) == 0 { - log.Debugf("[streamfs] Buffered %d bytes to ring (no readers, total chunks: %d)", - len(data), sf.totalChunks) - } else { - log.Debugf("[streamfs] Fanout %d bytes to %d readers (success: %d, dropped: %d, total chunks: %d)", - len(data), len(readerSnapshot), successCount, dropCount, sf.totalChunks) - } - - return nil -} - -// ReadChunk reads data from a reader's channel (blocking with timeout) -// Returns (data, eof, error) -// This method should be called after RegisterReader -func (sf *StreamFile) ReadChunk(readerID string, ch <-chan []byte, timeout time.Duration) ([]byte, bool, error) { - select { - case data, ok := <-ch: - if !ok { - // Channel closed - stream is closed or reader was unregistered - return nil, true, io.EOF - } - return data, false, nil - case <-time.After(timeout): - // Check if stream is closed - sf.mu.RLock() - closed := sf.closed - sf.mu.RUnlock() - - if closed { - return nil, true, io.EOF - } - return nil, false, fmt.Errorf("read timeout") - } -} - -// Close closes the stream and all reader channels -func (sf *StreamFile) Close() error { - sf.mu.Lock() - defer sf.mu.Unlock() - - sf.closed = true - - // Close all reader channels - for id, reader := range sf.readers { - close(reader.ch) - log.Infof("[streamfs] Closed reader %s for stream %s (dropped: %d chunks)", id, sf.name, reader.droppedCount) - } - // Clear readers map - sf.readers = make(map[string]*Reader) - - log.Infof("[streamfs] Stream %s closed", sf.name) - return nil -} - -// GetInfo returns file info -func (sf *StreamFile) GetInfo() filesystem.FileInfo { - sf.mu.RLock() - defer sf.mu.RUnlock() - - // Remove leading slash from name for display - name := sf.name - if len(name) > 0 && name[0] == '/' { - name = name[1:] - } - - return filesystem.FileInfo{ - Name: name, - Size: sf.offset, // Total bytes written - Mode: 0644, - ModTime: sf.modTime, - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "stream", - Content: map[string]string{ - "total_written": fmt.Sprintf("%d", sf.offset), - "active_readers": fmt.Sprintf("%d", len(sf.readers)), - }, - }, - } -} - -// StreamFS implements FileSystem interface for streaming files -type StreamFS struct { - streams map[string]*StreamFile - mu sync.RWMutex - channelBuffer int // Default channel buffer size per reader - ringSize int // Ring buffer size for historical data - pluginName string -} - -// NewStreamFS creates a new StreamFS -func NewStreamFS(channelBuffer int, ringSize int) *StreamFS { - if channelBuffer <= 0 { - channelBuffer = 100 // Default: 100 chunks per reader - } - if ringSize <= 0 { - ringSize = 100 // Default: 100 chunks in ring buffer - } - return &StreamFS{ - streams: make(map[string]*StreamFile), - channelBuffer: channelBuffer, - ringSize: ringSize, - pluginName: PluginName, - } -} - -func (sfs *StreamFS) Create(path string) error { - sfs.mu.Lock() - defer sfs.mu.Unlock() - - if _, exists := sfs.streams[path]; exists { - return fmt.Errorf("stream already exists: %s", path) - } - - sfs.streams[path] = NewStreamFile(path, sfs.channelBuffer, sfs.ringSize) - return nil -} - -func (sfs *StreamFS) Mkdir(path string, perm uint32) error { - return fmt.Errorf("streamfs does not support directories") -} - -func (sfs *StreamFS) Remove(path string) error { - sfs.mu.Lock() - defer sfs.mu.Unlock() - - stream, exists := sfs.streams[path] - if !exists { - return fmt.Errorf("stream not found: %s", path) - } - - stream.Close() - delete(sfs.streams, path) - return nil -} - -func (sfs *StreamFS) RemoveAll(path string) error { - return sfs.Remove(path) -} - -// Read is not suitable for streaming, use ReadChunk instead -// This is here for compatibility with FileSystem interface -func (sfs *StreamFS) Read(path string, offset int64, size int64) ([]byte, error) { - // README file can be read normally - if path == "/README" { - content := []byte(getReadme()) - return plugin.ApplyRangeRead(content, offset, size) - } - - // Stream files must use --stream mode - return nil, fmt.Errorf("use stream mode for reading stream files") -} - -func (sfs *StreamFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - sfs.mu.Lock() - stream, exists := sfs.streams[path] - if !exists { - // Auto-create stream on first write - stream = NewStreamFile(path, sfs.channelBuffer, sfs.ringSize) - sfs.streams[path] = stream - } - sfs.mu.Unlock() - - // StreamFS is append-only (broadcast), offset is ignored - err := stream.Write(data) - if err != nil { - return 0, err - } - - return int64(len(data)), nil -} - -func (sfs *StreamFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - if path != "/" { - return nil, fmt.Errorf("not a directory: %s", path) - } - - sfs.mu.RLock() - defer sfs.mu.RUnlock() - - readme := filesystem.FileInfo{ - Name: "README", - Size: int64(len(getReadme())), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - } - - files := []filesystem.FileInfo{readme} - for _, stream := range sfs.streams { - files = append(files, stream.GetInfo()) - } - - return files, nil -} - -func (sfs *StreamFS) Stat(path string) (*filesystem.FileInfo, error) { - if path == "/" { - info := &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - }, - } - return info, nil - } - - if path == "/README" { - readme := getReadme() - info := &filesystem.FileInfo{ - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - } - return info, nil - } - - sfs.mu.RLock() - stream, exists := sfs.streams[path] - sfs.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("stream not found: %s", path) - } - - info := stream.GetInfo() - return &info, nil -} - -func (sfs *StreamFS) Rename(oldPath, newPath string) error { - return fmt.Errorf("streamfs does not support rename") -} - -func (sfs *StreamFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("streamfs does not support chmod") -} - -func (sfs *StreamFS) Open(path string) (io.ReadCloser, error) { - if path == "/README" { - return io.NopCloser(bytes.NewReader([]byte(getReadme()))), nil - } - return nil, fmt.Errorf("use stream mode for reading stream files") -} - -func (sfs *StreamFS) OpenWrite(path string) (io.WriteCloser, error) { - return &streamWriter{sfs: sfs, path: path}, nil -} - -// OpenStream implements filesystem.Streamer interface -func (sfs *StreamFS) OpenStream(path string) (filesystem.StreamReader, error) { - sfs.mu.Lock() - stream, exists := sfs.streams[path] - if !exists { - // Auto-create stream if it doesn't exist (for readers to connect before writer) - stream = NewStreamFile(path, sfs.channelBuffer, sfs.ringSize) - sfs.streams[path] = stream - log.Infof("[streamfs] Auto-created stream %s for reader", path) - } - sfs.mu.Unlock() - - // Register a new reader - readerID, ch := stream.RegisterReader() - log.Infof("[streamfs] Opened stream %s with reader %s", path, readerID) - - return &streamReader{ - sf: stream, - readerID: readerID, - ch: ch, - }, nil -} - -// GetStream returns the stream for reading (deprecated, use OpenStream) -// Kept for backward compatibility -func (sfs *StreamFS) GetStream(path string) (interface{}, error) { - sfs.mu.Lock() - defer sfs.mu.Unlock() - - stream, exists := sfs.streams[path] - if !exists { - // Auto-create stream if it doesn't exist (for readers to connect before writer) - stream = NewStreamFile(path, sfs.channelBuffer, sfs.ringSize) - sfs.streams[path] = stream - log.Infof("[streamfs] Auto-created stream %s for reader", path) - } - - return stream, nil -} - -type streamWriter struct { - sfs *StreamFS - path string -} - -func (sw *streamWriter) Write(p []byte) (n int, err error) { - _, err = sw.sfs.Write(sw.path, p, -1, filesystem.WriteFlagAppend) - if err != nil { - return 0, err - } - return len(p), nil -} - -func (sw *streamWriter) Close() error { - return nil -} - -// StreamFSPlugin wraps StreamFS as a plugin -type StreamFSPlugin struct { - fs *StreamFS - channelBuffer int - ringSize int -} - -// NewStreamFSPlugin creates a new StreamFS plugin -func NewStreamFSPlugin() *StreamFSPlugin { - return &StreamFSPlugin{ - channelBuffer: 100, // Default: 100 chunks per reader channel - ringSize: 100, // Default: 100 chunks in ring buffer - } -} - -func (p *StreamFSPlugin) Name() string { - return PluginName -} - -func (p *StreamFSPlugin) Validate(cfg map[string]interface{}) error { - // Check for unknown parameters - allowedKeys := []string{"channel_buffer_size", "ring_buffer_size", "mount_path"} - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate channel_buffer_size if provided - if val, exists := cfg["channel_buffer_size"]; exists { - switch v := val.(type) { - case string: - if _, err := config.ParseSize(v); err != nil { - return fmt.Errorf("invalid channel_buffer_size: %w", err) - } - case int, int64, float64: - // Valid numeric types - default: - return fmt.Errorf("channel_buffer_size must be a size string (e.g., '512KB') or number") - } - } - - // Validate ring_buffer_size if provided - if val, exists := cfg["ring_buffer_size"]; exists { - switch v := val.(type) { - case string: - if _, err := config.ParseSize(v); err != nil { - return fmt.Errorf("invalid ring_buffer_size: %w", err) - } - case int, int64, float64: - // Valid numeric types - default: - return fmt.Errorf("ring_buffer_size must be a size string (e.g., '1MB') or number") - } - } - - return nil -} - -func (p *StreamFSPlugin) Initialize(config map[string]interface{}) error { - const defaultChunkSize = 64 * 1024 // 64KB per chunk - - // Parse channel buffer size from config (support both bytes and string with units) - channelBufferBytes := int64(6 * 1024 * 1024) // Default: 6MB - if bufSizeStr, ok := config["channel_buffer_size"].(string); ok { - if parsed, err := parseSize(bufSizeStr); err == nil { - channelBufferBytes = parsed - } else { - log.Warnf("[streamfs] Invalid channel_buffer_size '%s': %v, using default", bufSizeStr, err) - } - } else if bufSize, ok := config["channel_buffer_size"].(int); ok { - channelBufferBytes = int64(bufSize) - } else if bufSizeFloat, ok := config["channel_buffer_size"].(float64); ok { - channelBufferBytes = int64(bufSizeFloat) - } else if bufSizeInt64, ok := config["channel_buffer_size"].(int64); ok { - channelBufferBytes = bufSizeInt64 - } - - // Parse ring buffer size from config (support both bytes and string with units) - ringBufferBytes := int64(6 * 1024 * 1024) // Default: 6MB - if ringSizeStr, ok := config["ring_buffer_size"].(string); ok { - if parsed, err := parseSize(ringSizeStr); err == nil { - ringBufferBytes = parsed - } else { - log.Warnf("[streamfs] Invalid ring_buffer_size '%s': %v, using default", ringSizeStr, err) - } - } else if ringSize, ok := config["ring_buffer_size"].(int); ok { - ringBufferBytes = int64(ringSize) - } else if ringSizeFloat, ok := config["ring_buffer_size"].(float64); ok { - ringBufferBytes = int64(ringSizeFloat) - } else if ringSizeInt64, ok := config["ring_buffer_size"].(int64); ok { - ringBufferBytes = ringSizeInt64 - } - - // Convert bytes to number of chunks - p.channelBuffer = int(channelBufferBytes / defaultChunkSize) - if p.channelBuffer < 1 { - p.channelBuffer = 1 - } - - p.ringSize = int(ringBufferBytes / defaultChunkSize) - if p.ringSize < 1 { - p.ringSize = 1 - } - - p.fs = NewStreamFS(p.channelBuffer, p.ringSize) - log.Infof("[streamfs] Initialized with channel buffer: %s (%d chunks), ring buffer: %s (%d chunks)", - formatSize(channelBufferBytes), p.channelBuffer, - formatSize(ringBufferBytes), p.ringSize) - return nil -} - -func (p *StreamFSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *StreamFSPlugin) GetReadme() string { - return getReadme() -} - -func (p *StreamFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "channel_buffer_size", - Type: "string", - Required: false, - Default: "512KB", - Description: "Channel buffer size (e.g., '512KB', '1MB')", - }, - { - Name: "ring_buffer_size", - Type: "string", - Required: false, - Default: "1MB", - Description: "Ring buffer size (e.g., '1MB', '10MB')", - }, - } -} - -func (p *StreamFSPlugin) Shutdown() error { - return nil -} - -func getReadme() string { - return `StreamFS Plugin - Streaming File System - -This plugin provides streaming files that support multiple concurrent readers and writers -with real-time data fanout and ring buffer for late joiners. - -FEATURES: - - Multiple writers can append data to a stream concurrently - - Multiple readers can consume from the stream independently (fanout/broadcast) - - Ring buffer (1000 chunks) stores recent data for late-joining readers - - Persistent streaming: readers wait indefinitely for new data (no timeout disconnect) - - HTTP chunked transfer with automatic flow control - - Memory-based storage with configurable channel buffer per reader - -ARCHITECTURE: - - Each stream maintains a ring buffer of recent chunks (default: last 1000 chunks) - - New readers automatically receive all available historical data from ring buffer - - Writers fanout data to all active readers via buffered channels - - Readers wait indefinitely for new data (30s check interval, but never disconnect) - - Slow readers may drop chunks if their channel buffer fills up - -COMMAND REFERENCE: - - Write (Producer): - cat file | agfs write --stream /streamfs/stream - echo "data" | agfs write /streamfs/stream - - Read (Consumer): - agfs cat --stream /streamfs/stream - agfs cat --stream /streamfs/stream > output.dat - agfs cat --stream /streamfs/stream | ffplay - - - Manage: - agfs ls /streamfs - agfs stat /streamfs/stream - agfs rm /streamfs/stream - -CONFIGURATION: - - [plugins.streamfs] - enabled = true - path = "/streamfs" - - [plugins.streamfs.config] - # Channel buffer size per reader (supports units: KB, MB, GB or raw bytes) - # Controls how much data each reader can buffer before dropping chunks - # For live streaming: 256KB - 512KB (low latency) - # For VOD/recording: 4MB - 8MB (smooth playback) - # Default: 6MB - # Examples: "512KB", "1MB", "6MB", or 524288 (bytes) - channel_buffer_size = "512KB" - - # Ring buffer size for historical data (supports units: KB, MB, GB or raw bytes) - # Stores recent data for late-joining readers - # For live streaming: 512KB - 1MB (low latency, less memory) - # For VOD: 4MB - 8MB (more history for seekable playback) - # Default: 6MB - # Examples: "1MB", "4MB", or 1048576 (bytes) - ring_buffer_size = "1MB" - -IMPORTANT NOTES: - - - Streams are in-memory only (not persistent across restarts) - - Ring buffer stores recent data (configurable, default 6MB) - - Late-joining readers receive historical data from ring buffer - - Readers never timeout - they wait indefinitely for new data - - Writer chunk size: 64KB (configured in CLI write --stream) - - Channel buffer: configurable per reader (default 6MB) - - Slow readers may drop chunks if they can't keep up - - MUST use --stream flag for reading streams (cat --stream) - - Regular cat without --stream will fail with error - -MEMORY USAGE: - - File Size vs Memory Usage: - - 'ls' and 'stat' show TOTAL BYTES WRITTEN (cumulative counter) - - This is NOT the actual memory usage - just a throughput statistic - - Example: Stream shows 1GB in 'ls', but only uses 6MB RAM (ring buffer) - - The file size will continuously grow as data is written - - This is similar to /dev/null - unlimited writes, fixed memory - - Actual Memory Footprint: - - Ring buffer: Fixed at ring_buffer_size (default: 6MB) - - Per reader channel: Fixed at channel_buffer_size (default: 6MB per reader) - - Total memory = ring_buffer_size + (channel_buffer_size × number of readers) - - Example with 3 readers: 6MB (ring) + 3×6MB (readers) = 24MB total - - Old data in ring buffer is automatically overwritten (circular buffer) - - No disk space is used - everything is in memory only - - Overflow Protection: - - All counters use int64 to prevent overflow (max: 9.2 EB ≈ 292 years at 1GB/s) - - Ring buffer index calculations are overflow-safe on both 32-bit and 64-bit systems - - Stream can run indefinitely without counter overflow concerns - -PERFORMANCE TIPS: - - - For live streaming: Use smaller buffers (256KB-512KB) to reduce latency - - For VOD/recording: Use larger buffers (4MB-8MB) for smoother playback - - For video streaming: Start writer first to fill ring buffer - - Increase channel_buffer_size for high-bitrate streams - - Decrease buffer sizes for interactive/live use cases - - Monitor dropped chunks in logs (indicates slow readers) - - Example low-latency config: channel=256KB, ring=512KB - - Example high-throughput config: channel=8MB, ring=16MB - -TROUBLESHOOTING: - - - Error "use stream mode": Use 'cat --stream' instead of 'cat' - - Reader disconnects: Check if writer finished (readers wait indefinitely otherwise) - - High memory usage: Reduce channel_buffer_size or limit concurrent readers - -ARCHITECTURE DETAILS: - - - StreamFS implements filesystem.Streamer interface - - Each reader gets a filesystem.StreamReader with independent position - - Ring buffer enables time-shifting and late joining - - Fanout is non-blocking: slow readers drop chunks, fast readers proceed - - Graceful shutdown: closing stream sends EOF to all readers -` -} - -// Ensure StreamFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*StreamFSPlugin)(nil) -var _ filesystem.FileSystem = (*StreamFS)(nil) -var _ filesystem.HandleFS = (*StreamFS)(nil) - -// ============================================================================ -// HandleFS Implementation for StreamFS -// ============================================================================ - -// Maximum buffer size before trimming (1MB sliding window) -const maxServerStreamBufferSize = 1 * 1024 * 1024 - -// streamFileHandle represents an open handle to a stream file -type streamFileHandle struct { - id int64 - sfs *StreamFS - path string - flags filesystem.OpenFlag - stream *StreamFile - - // For reading: registered reader info - readerID string - ch <-chan []byte - - // Read buffer: sliding window to prevent memory leak - readBuffer []byte - readBase int64 // Base offset of readBuffer[0] in the logical stream - readOffset int64 // Current read position in logical stream - readClosed bool // Whether the read side is closed (EOF received) - - mu sync.Mutex -} - -// streamHandleManager manages open handles for StreamFS -type streamHandleManager struct { - handles map[int64]*streamFileHandle - nextID int64 - mu sync.Mutex -} - -// Global handle manager for StreamFS -var sfsHandleManager = &streamHandleManager{ - handles: make(map[int64]*streamFileHandle), - nextID: 1, -} - -// OpenHandle opens a file and returns a handle for stateful operations -func (sfs *StreamFS) OpenHandle(path string, flags filesystem.OpenFlag, mode uint32) (filesystem.FileHandle, error) { - // README file - use simple read - if path == "/README" { - sfsHandleManager.mu.Lock() - defer sfsHandleManager.mu.Unlock() - - id := sfsHandleManager.nextID - sfsHandleManager.nextID++ - - handle := &streamFileHandle{ - id: id, - sfs: sfs, - path: path, - flags: flags, - readBuffer: []byte(getReadme()), - readClosed: true, // README is static, no more data - } - - sfsHandleManager.handles[id] = handle - log.Debugf("[streamfs] Opened README handle %d", id) - return handle, nil - } - - // Get or create stream - sfs.mu.Lock() - stream, exists := sfs.streams[path] - if !exists { - // Auto-create stream if it doesn't exist - stream = NewStreamFile(path, sfs.channelBuffer, sfs.ringSize) - sfs.streams[path] = stream - log.Infof("[streamfs] Auto-created stream %s for handle", path) - } - sfs.mu.Unlock() - - sfsHandleManager.mu.Lock() - defer sfsHandleManager.mu.Unlock() - - id := sfsHandleManager.nextID - sfsHandleManager.nextID++ - - handle := &streamFileHandle{ - id: id, - sfs: sfs, - path: path, - flags: flags, - stream: stream, - } - - // If opening for read, register as a reader - if flags&filesystem.O_WRONLY == 0 { - readerID, ch := stream.RegisterReader() - handle.readerID = readerID - handle.ch = ch - log.Infof("[streamfs] Opened read handle %d for %s (reader: %s)", id, path, readerID) - } else { - log.Infof("[streamfs] Opened write handle %d for %s", id, path) - } - - sfsHandleManager.handles[id] = handle - return handle, nil -} - -// GetHandle retrieves an existing handle by its ID -func (sfs *StreamFS) GetHandle(id int64) (filesystem.FileHandle, error) { - sfsHandleManager.mu.Lock() - defer sfsHandleManager.mu.Unlock() - - handle, ok := sfsHandleManager.handles[id] - if !ok { - return nil, filesystem.ErrNotFound - } - return handle, nil -} - -// CloseHandle closes a handle by its ID -func (sfs *StreamFS) CloseHandle(id int64) error { - sfsHandleManager.mu.Lock() - handle, ok := sfsHandleManager.handles[id] - if !ok { - sfsHandleManager.mu.Unlock() - return filesystem.ErrNotFound - } - delete(sfsHandleManager.handles, id) - sfsHandleManager.mu.Unlock() - - // Unregister reader if this was a read handle - if handle.readerID != "" && handle.stream != nil { - handle.stream.UnregisterReader(handle.readerID) - log.Infof("[streamfs] Closed handle %d, unregistered reader %s", id, handle.readerID) - } - - return nil -} - -// ============================================================================ -// FileHandle Implementation -// ============================================================================ - -func (h *streamFileHandle) ID() int64 { - return h.id -} - -func (h *streamFileHandle) Path() string { - return h.path -} - -func (h *streamFileHandle) Flags() filesystem.OpenFlag { - return h.flags -} - -func (h *streamFileHandle) Read(buf []byte) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - return h.readLocked(buf) -} - -func (h *streamFileHandle) ReadAt(buf []byte, offset int64) (int, error) { - h.mu.Lock() - defer h.mu.Unlock() - - // First, try to collect all available data without blocking - h.drainAvailableData() - - // If we already have enough data for this request, return it - if offset < int64(len(h.readBuffer)) { - end := offset + int64(len(buf)) - if end > int64(len(h.readBuffer)) { - end = int64(len(h.readBuffer)) - } - n := copy(buf, h.readBuffer[offset:end]) - - // If stream is closed and we've returned all data - if h.readClosed && end >= int64(len(h.readBuffer)) && n < len(buf) { - return n, io.EOF - } - return n, nil - } - - // No data at requested offset yet - if h.readClosed { - return 0, io.EOF - } - - // Wait for more data (with timeout) - if err := h.fetchMoreData(); err != nil { - if err == io.EOF { - h.readClosed = true - return 0, io.EOF - } - return 0, err - } - - // Try again after fetching - if offset < int64(len(h.readBuffer)) { - end := offset + int64(len(buf)) - if end > int64(len(h.readBuffer)) { - end = int64(len(h.readBuffer)) - } - n := copy(buf, h.readBuffer[offset:end]) - return n, nil - } - - // Still no data - return 0 bytes (FUSE will retry) - return 0, nil -} - -// drainAvailableData collects all immediately available data from channel -func (h *streamFileHandle) drainAvailableData() { - if h.ch == nil { - return - } - - for { - select { - case data, ok := <-h.ch: - if !ok { - h.readClosed = true - return - } - h.readBuffer = append(h.readBuffer, data...) - default: - // No more data immediately available - return - } - } -} - -// readLocked reads data (must hold mutex) -// Uses sliding window buffer to prevent memory leak -func (h *streamFileHandle) readLocked(buf []byte) (int, error) { - // Convert logical offset to relative offset in buffer - relOffset := h.readOffset - h.readBase - - // First, return any buffered data - if relOffset >= 0 && relOffset < int64(len(h.readBuffer)) { - n := copy(buf, h.readBuffer[relOffset:]) - h.readOffset += int64(n) - - // Trim old data if buffer is too large - h.trimBuffer() - - return n, nil - } - - // If stream is closed, return EOF - if h.readClosed { - return 0, io.EOF - } - - // Fetch more data from stream - if err := h.fetchMoreData(); err != nil { - if err == io.EOF { - h.readClosed = true - return 0, io.EOF - } - return 0, err - } - - // Recalculate relative offset - relOffset = h.readOffset - h.readBase - - // Return newly fetched data - if relOffset >= 0 && relOffset < int64(len(h.readBuffer)) { - n := copy(buf, h.readBuffer[relOffset:]) - h.readOffset += int64(n) - - // Trim old data if buffer is too large - h.trimBuffer() - - return n, nil - } - - return 0, nil -} - -// trimBuffer removes old data from buffer to prevent memory leak -// Must be called with mutex held -func (h *streamFileHandle) trimBuffer() { - if len(h.readBuffer) <= maxServerStreamBufferSize { - return - } - - // Calculate how much data has been consumed - consumed := h.readOffset - h.readBase - if consumed <= 0 { - return - } - - // Keep 64KB margin for potential re-reads - margin := int64(64 * 1024) - trimPoint := consumed - margin - if trimPoint <= 0 { - return - } - - if trimPoint > 0 && trimPoint < int64(len(h.readBuffer)) { - // Trim the buffer - newBuffer := make([]byte, int64(len(h.readBuffer))-trimPoint) - copy(newBuffer, h.readBuffer[trimPoint:]) - h.readBuffer = newBuffer - h.readBase += trimPoint - log.Debugf("[streamfs] Trimmed handle buffer: new base=%d, new size=%d", h.readBase, len(h.readBuffer)) - } -} - -// fetchMoreData fetches more data from the stream channel -// Uses timeout to avoid HTTP request timeout (FUSE client has 60s timeout) -func (h *streamFileHandle) fetchMoreData() error { - if h.ch == nil { - return io.EOF - } - - // Use 30 second timeout to stay within HTTP timeout limit - // Long enough for streams, short enough to avoid HTTP timeout - select { - case data, ok := <-h.ch: - if !ok { - return io.EOF - } - h.readBuffer = append(h.readBuffer, data...) - return nil - case <-time.After(30 * time.Second): - // Timeout - return what we have, don't error - // The caller will return buffered data or retry - return nil - } -} - -func (h *streamFileHandle) Write(data []byte) (int, error) { - return h.WriteAt(data, 0) -} - -func (h *streamFileHandle) WriteAt(data []byte, offset int64) (int, error) { - if h.stream == nil { - return 0, fmt.Errorf("stream not initialized") - } - - // StreamFS is append-only, offset is ignored - err := h.stream.Write(data) - if err != nil { - return 0, err - } - - return len(data), nil -} - -func (h *streamFileHandle) Seek(offset int64, whence int) (int64, error) { - h.mu.Lock() - defer h.mu.Unlock() - - var newOffset int64 - switch whence { - case io.SeekStart: - newOffset = offset - case io.SeekCurrent: - newOffset = h.readOffset + offset - case io.SeekEnd: - // For streams, end is the current buffer length - newOffset = int64(len(h.readBuffer)) + offset - default: - return 0, fmt.Errorf("invalid whence: %d", whence) - } - - if newOffset < 0 { - return 0, fmt.Errorf("negative offset") - } - - h.readOffset = newOffset - return newOffset, nil -} - -func (h *streamFileHandle) Sync() error { - // Nothing to sync for streams - return nil -} - -func (h *streamFileHandle) Close() error { - h.mu.Lock() - defer h.mu.Unlock() - - sfsHandleManager.mu.Lock() - delete(sfsHandleManager.handles, h.id) - sfsHandleManager.mu.Unlock() - - // Unregister reader - if h.readerID != "" && h.stream != nil { - h.stream.UnregisterReader(h.readerID) - log.Infof("[streamfs] Handle %d closed, unregistered reader %s", h.id, h.readerID) - } - - return nil -} - -func (h *streamFileHandle) Stat() (*filesystem.FileInfo, error) { - return h.sfs.Stat(h.path) -} diff --git a/third_party/agfs/agfs-server/pkg/plugins/streamrotatefs/README.md b/third_party/agfs/agfs-server/pkg/plugins/streamrotatefs/README.md deleted file mode 100644 index 786ee9dad..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/streamrotatefs/README.md +++ /dev/null @@ -1,142 +0,0 @@ -# StreamRotateFS Plugin - Rotating Streaming File System - -This plugin extends StreamFS with automatic file rotation support. Data is streamed to readers while being saved to rotating files on local filesystem. - -## Features - -- **All StreamFS features**: Multiple readers/writers, ring buffer, fanout -- **Time-based rotation**: Rotate files at specified intervals (e.g., every 5 minutes) -- **Size-based rotation**: Rotate files when reaching size threshold (e.g., 100MB) -- **Configurable output path**: Save to local directory -- **Customizable filename pattern**: Use variables for dynamic naming -- **Concurrent operation**: Rotation doesn't interrupt streaming - -## Rotation Triggers - -- **Time interval**: Files rotate after specified duration (`rotation_interval`) -- **File size**: Files rotate when reaching size threshold (`rotation_size`) -- Both can be enabled simultaneously (triggers on first condition met) - -## Filename Pattern Variables - -- `{channel}` - Channel/stream name -- `{timestamp}` - Unix timestamp (seconds) -- `{date}` - Date in YYYYMMDD format -- `{time}` - Time in HHMMSS format -- `{datetime}` - Date and time in YYYYMMDD_HHMMSS format -- `{index}` - Rotation file index (6-digit zero-padded) - -## Usage Examples - -### Write to rotating stream -```bash -cat video.mp4 | agfs write --stream /streamrotatefs/channel1 -``` - -### Read from stream (live) -```bash -agfs cat --stream /streamrotatefs/channel1 | ffplay - -``` - -### List rotated files -```bash -agfs ls /s3fs/bucket/streams/ -# OR -agfs ls /localfs/data/ -``` - -## Configuration - -```toml -[plugins.streamrotatefs] -enabled = true -path = "/streamrotatefs" - - [plugins.streamrotatefs.config] - # Stream buffer settings (same as streamfs) - channel_buffer_size = "6MB" - ring_buffer_size = "6MB" - - # Rotation settings - rotation_interval = "5m" # Rotate every 5 minutes - rotation_size = "100MB" # Rotate at 100MB - - # Output path - must be an AGFS path - output_path = "/s3fs/my-bucket/streams" # Save to S3 via s3fs - # OR - # output_path = "/localfs/data" # Save via localfs - - filename_pattern = "{channel}_{datetime}_{index}.dat" -``` - -### Output Path - -- **Must be an AGFS path** (starts with `/`) - - Example: `"/s3fs/bucket/path"` - Save to S3 - - Example: `"/localfs/data"` - Save via localfs plugin - - Supports any mounted agfs filesystem - - The target mount point must be already mounted and writable - -## Configuration Examples - -### Time-based rotation (every hour) -```toml -rotation_interval = "1h" -rotation_size = "" # Disabled -``` - -### Size-based rotation (100MB chunks) -```toml -rotation_interval = "" # Disabled -rotation_size = "100MB" -``` - -### Combined (whichever comes first) -```toml -rotation_interval = "10m" -rotation_size = "50MB" -``` - -## Filename Pattern Examples - -``` -{channel}_{timestamp}.dat - → channel1_1702345678.dat - -{date}/{channel}_{time}.mp4 - → 20231207/channel1_143058.mp4 - -{channel}/segment_{index}.ts - → channel1/segment_000001.ts -``` - -## Important Notes - -- **Output path must be an AGFS path** (e.g., `/s3fs/bucket` or `/localfs/data`) -- The target mount point must be already mounted and writable -- Parent directories will be created automatically if the filesystem supports it -- Stream continues uninterrupted during rotation -- Old rotation files are not automatically deleted -- Readers receive live data regardless of rotation -- File index increments with each rotation - -## Dynamic Mounting - -### Interactive shell - Default settings -```bash -agfs:/> mount streamrotatefs /rotate output_path=/localfs/rotated -``` - -### Interactive shell - Custom settings -```bash -agfs:/> mount streamrotatefs /rotate rotation_interval=5m rotation_size=100MB output_path=/s3fs/data -``` - -### Direct command -```bash -uv run agfs mount streamrotatefs /rotate rotation_size=50MB output_path=/s3fs/output -``` - -## License - -Apache License 2.0 diff --git a/third_party/agfs/agfs-server/pkg/plugins/streamrotatefs/streamrotatefs.go b/third_party/agfs/agfs-server/pkg/plugins/streamrotatefs/streamrotatefs.go deleted file mode 100644 index 2216a4349..000000000 --- a/third_party/agfs/agfs-server/pkg/plugins/streamrotatefs/streamrotatefs.go +++ /dev/null @@ -1,1059 +0,0 @@ -package streamrotatefs - -import ( - "bytes" - "fmt" - "io" - "path" - "strconv" - "strings" - "sync" - "time" - - "github.com/c4pt0r/agfs/agfs-server/pkg/filesystem" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin" - "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/config" - log "github.com/sirupsen/logrus" -) - -const ( - PluginName = "streamrotatefs" // Name of this plugin -) - -// parseSize parses a size string like "512KB", "1MB", "100MB" and returns bytes -func parseSize(s string) (int64, error) { - s = strings.TrimSpace(strings.ToUpper(s)) - - // Handle pure numbers (bytes) - if val, err := strconv.ParseInt(s, 10, 64); err == nil { - return val, nil - } - - // Parse with unit suffix - units := map[string]int64{ - "B": 1, - "KB": 1024, - "MB": 1024 * 1024, - "GB": 1024 * 1024 * 1024, - } - - for suffix, multiplier := range units { - if strings.HasSuffix(s, suffix) { - numStr := strings.TrimSuffix(s, suffix) - numStr = strings.TrimSpace(numStr) - - // Try parsing as float first (for "1.5MB") - if val, err := strconv.ParseFloat(numStr, 64); err == nil { - return int64(val * float64(multiplier)), nil - } - } - } - - return 0, fmt.Errorf("invalid size format: %s (expected format: 512KB, 1MB, etc)", s) -} - -// parseDuration parses a duration string like "5m", "1h", "30s" -func parseDuration(s string) (time.Duration, error) { - return time.ParseDuration(s) -} - -// formatSize formats bytes into human-readable format -func formatSize(bytes int64) string { - const unit = 1024 - if bytes < unit { - return fmt.Sprintf("%dB", bytes) - } - div, exp := int64(unit), 0 - for n := bytes / unit; n >= unit; n /= unit { - div *= unit - exp++ - } - units := []string{"KB", "MB", "GB", "TB"} - if exp >= len(units) { - exp = len(units) - 1 - } - return fmt.Sprintf("%.1f%s", float64(bytes)/float64(div), units[exp]) -} - -// Reader represents a single reader with its channel and metadata -type Reader struct { - id string - ch chan []byte - registered time.Time - droppedCount int64 - readIndex int64 -} - -// streamReader wraps a registered reader and implements filesystem.StreamReader -type streamReader struct { - rsf *RotateStreamFile - readerID string - ch <-chan []byte -} - -// ReadChunk implements filesystem.StreamReader -func (sr *streamReader) ReadChunk(timeout time.Duration) ([]byte, bool, error) { - return sr.rsf.ReadChunk(sr.readerID, sr.ch, timeout) -} - -// Close implements filesystem.StreamReader -func (sr *streamReader) Close() error { - sr.rsf.UnregisterReader(sr.readerID) - return nil -} - -// RotationConfig holds configuration for file rotation -type RotationConfig struct { - RotationInterval time.Duration // Time-based rotation interval - RotationSize int64 // Size-based rotation threshold (bytes) - OutputPath string // Output directory path (agfs path like /s3fs/bucket) - FilenamePattern string // Filename pattern with variables -} - -// RotateStreamFile represents a streaming file with rotation support -type RotateStreamFile struct { - name string - channel string // Channel name (extracted from path) - mu sync.RWMutex - offset int64 // Total bytes written - closed bool // Whether the stream is closed - modTime time.Time // Last modification time - readers map[string]*Reader // All registered readers - nextReaderID int // Auto-increment reader ID - channelBuffer int // Buffer size for each reader channel - - // Ring buffer for storing recent chunks - ringBuffer [][]byte - ringSize int - writeIndex int64 - totalChunks int64 - - // Rotation-specific fields - config RotationConfig - currentWriter io.WriteCloser // Current output file writer (can be os.File or agfs writer) - currentFileSize int64 // Size of current rotation file - fileIndex int64 // Rotation file index - rotationTimer *time.Timer // Timer for time-based rotation - stopRotation chan bool // Signal to stop rotation goroutine - currentFilePath string // Current output file path - parentFS filesystem.FileSystem // Reference to parent agfs filesystem -} - -// NewRotateStreamFile creates a new rotate stream file -func NewRotateStreamFile(name string, channelBuffer int, ringSize int, config RotationConfig, parentFS filesystem.FileSystem) *RotateStreamFile { - if channelBuffer <= 0 { - channelBuffer = 100 - } - if ringSize <= 0 { - ringSize = 100 - } - - // Extract channel name from path - channel := path.Base(name) - - rsf := &RotateStreamFile{ - name: name, - channel: channel, - modTime: time.Now(), - readers: make(map[string]*Reader), - nextReaderID: 0, - channelBuffer: channelBuffer, - ringBuffer: make([][]byte, ringSize), - ringSize: ringSize, - writeIndex: 0, - totalChunks: 0, - config: config, - fileIndex: 0, - stopRotation: make(chan bool), - parentFS: parentFS, - } - - // Start rotation timer if interval is configured - if config.RotationInterval > 0 { - rsf.startRotationTimer() - } - - return rsf -} - -// startRotationTimer starts a goroutine for time-based rotation -func (rsf *RotateStreamFile) startRotationTimer() { - go func() { - for { - select { - case <-time.After(rsf.config.RotationInterval): - rsf.mu.Lock() - if !rsf.closed && rsf.currentWriter != nil { - log.Infof("[streamrotatefs] Time-based rotation triggered for %s", rsf.name) - rsf.rotateFile() - } - rsf.mu.Unlock() - case <-rsf.stopRotation: - return - } - } - }() -} - -// generateFilename generates a filename based on the pattern -func (rsf *RotateStreamFile) generateFilename() string { - pattern := rsf.config.FilenamePattern - if pattern == "" { - pattern = "{channel}_{timestamp}.dat" - } - - now := time.Now() - replacements := map[string]string{ - "{channel}": rsf.channel, - "{timestamp}": fmt.Sprintf("%d", now.Unix()), - "{date}": now.Format("20060102"), - "{time}": now.Format("150405"), - "{index}": fmt.Sprintf("%06d", rsf.fileIndex), - "{datetime}": now.Format("20060102_150405"), - } - - filename := pattern - for key, value := range replacements { - filename = strings.ReplaceAll(filename, key, value) - } - - return filename -} - -// rotateFile closes current file and creates a new one -func (rsf *RotateStreamFile) rotateFile() error { - // Close current file if exists - if rsf.currentWriter != nil { - if err := rsf.currentWriter.Close(); err != nil { - log.Errorf("[streamrotatefs] Error closing current file: %v", err) - } - rsf.currentWriter = nil - rsf.currentFileSize = 0 - } - - if rsf.parentFS == nil { - return fmt.Errorf("parent filesystem not set, cannot write rotation files") - } - - // Generate new filename - filename := rsf.generateFilename() - outputPath := path.Join(rsf.config.OutputPath, filename) - - // Create parent directories if needed (for patterns like {date}/{channel}.dat) - parentDir := path.Dir(outputPath) - if parentDir != rsf.config.OutputPath && parentDir != "/" { - // Check if parent directory exists in agfs - if _, err := rsf.parentFS.Stat(parentDir); err != nil { - // Try to create parent directory - if err := rsf.parentFS.Mkdir(parentDir, 0755); err != nil { - log.Warnf("[streamrotatefs] Could not create parent directory %s: %v", parentDir, err) - } - } - } - - // Create file in agfs - if err := rsf.parentFS.Create(outputPath); err != nil { - log.Errorf("[streamrotatefs] Error creating agfs file %s: %v", outputPath, err) - return err - } - - // Open for writing - writer, err := rsf.parentFS.OpenWrite(outputPath) - if err != nil { - log.Errorf("[streamrotatefs] Error opening agfs file for write %s: %v", outputPath, err) - return err - } - - rsf.currentWriter = writer - rsf.currentFilePath = outputPath - rsf.fileIndex++ - - log.Infof("[streamrotatefs] Rotated to new file: %s (index: %d)", outputPath, rsf.fileIndex) - return nil -} - -// RegisterReader registers a new reader and returns reader ID and channel -func (rsf *RotateStreamFile) RegisterReader() (string, <-chan []byte) { - rsf.mu.Lock() - defer rsf.mu.Unlock() - - readerID := fmt.Sprintf("reader_%d_%d", rsf.nextReaderID, time.Now().UnixNano()) - rsf.nextReaderID++ - - historyStart := rsf.totalChunks - int64(rsf.ringSize) - if historyStart < 0 { - historyStart = 0 - } - - reader := &Reader{ - id: readerID, - ch: make(chan []byte, rsf.channelBuffer), - registered: time.Now(), - droppedCount: 0, - readIndex: historyStart, - } - rsf.readers[readerID] = reader - - log.Infof("[streamrotatefs] Registered reader %s for stream %s", readerID, rsf.name) - - // Send historical data - go rsf.sendHistoricalData(reader) - - return readerID, reader.ch -} - -// sendHistoricalData sends historical chunks from ring buffer to a new reader -func (rsf *RotateStreamFile) sendHistoricalData(reader *Reader) { - rsf.mu.RLock() - defer rsf.mu.RUnlock() - - historyStart := rsf.totalChunks - int64(rsf.ringSize) - if historyStart < 0 { - historyStart = 0 - } - - if reader.readIndex < rsf.totalChunks && rsf.totalChunks > 0 { - for i := historyStart; i < rsf.totalChunks; i++ { - ringIdx := int(i % int64(rsf.ringSize)) - if rsf.ringBuffer[ringIdx] != nil { - select { - case reader.ch <- rsf.ringBuffer[ringIdx]: - // Sent successfully - default: - log.Warnf("[streamrotatefs] Reader %s channel full during historical data send", reader.id) - return - } - } - } - } -} - -// UnregisterReader unregisters a reader and closes its channel -func (rsf *RotateStreamFile) UnregisterReader(readerID string) { - rsf.mu.Lock() - defer rsf.mu.Unlock() - - if reader, exists := rsf.readers[readerID]; exists { - close(reader.ch) - delete(rsf.readers, readerID) - log.Infof("[streamrotatefs] Unregistered reader %s for stream %s", readerID, rsf.name) - } -} - -// Write appends data to the stream, writes to rotation file, and fanout to all readers -func (rsf *RotateStreamFile) Write(data []byte) error { - rsf.mu.Lock() - - if rsf.closed { - rsf.mu.Unlock() - return fmt.Errorf("stream is closed") - } - - // Check if we need to rotate based on size - if rsf.config.RotationSize > 0 && rsf.currentWriter != nil { - if rsf.currentFileSize+int64(len(data)) > rsf.config.RotationSize { - log.Infof("[streamrotatefs] Size-based rotation triggered for %s (current: %d, threshold: %d)", - rsf.name, rsf.currentFileSize, rsf.config.RotationSize) - rsf.rotateFile() - } - } - - // Create first rotation file if needed - if rsf.currentWriter == nil { - if err := rsf.rotateFile(); err != nil { - rsf.mu.Unlock() - return fmt.Errorf("failed to create rotation file: %w", err) - } - } - - // Write to rotation file - if rsf.currentWriter != nil { - n, err := rsf.currentWriter.Write(data) - if err != nil { - log.Errorf("[streamrotatefs] Error writing to rotation file: %v", err) - } else { - rsf.currentFileSize += int64(n) - } - } - - // Copy data to avoid external modification - chunk := make([]byte, len(data)) - copy(chunk, data) - - rsf.offset += int64(len(data)) - rsf.modTime = time.Now() - - // Store in ring buffer - ringIdx := int(rsf.writeIndex % int64(rsf.ringSize)) - rsf.ringBuffer[ringIdx] = chunk - rsf.writeIndex++ - rsf.totalChunks++ - - // Take snapshot of readers - readerSnapshot := make([]*Reader, 0, len(rsf.readers)) - for _, reader := range rsf.readers { - readerSnapshot = append(readerSnapshot, reader) - } - - rsf.mu.Unlock() - - // Fanout to all readers - for _, reader := range readerSnapshot { - select { - case reader.ch <- chunk: - // Sent successfully - default: - reader.droppedCount++ - log.Warnf("[streamrotatefs] Reader %s is slow, dropped chunk", reader.id) - } - } - - return nil -} - -// ReadChunk reads data from a reader's channel -func (rsf *RotateStreamFile) ReadChunk(readerID string, ch <-chan []byte, timeout time.Duration) ([]byte, bool, error) { - select { - case data, ok := <-ch: - if !ok { - return nil, true, io.EOF - } - return data, false, nil - case <-time.After(timeout): - rsf.mu.RLock() - closed := rsf.closed - rsf.mu.RUnlock() - - if closed { - return nil, true, io.EOF - } - return nil, false, fmt.Errorf("read timeout") - } -} - -// Close closes the stream and all reader channels -func (rsf *RotateStreamFile) Close() error { - rsf.mu.Lock() - defer rsf.mu.Unlock() - - rsf.closed = true - - // Stop rotation timer - if rsf.config.RotationInterval > 0 { - close(rsf.stopRotation) - } - - // Close current rotation file - if rsf.currentWriter != nil { - rsf.currentWriter.Close() - rsf.currentWriter = nil - } - - // Close all reader channels - for id, reader := range rsf.readers { - close(reader.ch) - log.Infof("[streamrotatefs] Closed reader %s for stream %s", id, rsf.name) - } - rsf.readers = make(map[string]*Reader) - - log.Infof("[streamrotatefs] Stream %s closed", rsf.name) - return nil -} - -// GetInfo returns file info -func (rsf *RotateStreamFile) GetInfo() filesystem.FileInfo { - rsf.mu.RLock() - defer rsf.mu.RUnlock() - - name := rsf.name - if len(name) > 0 && name[0] == '/' { - name = name[1:] - } - - return filesystem.FileInfo{ - Name: name, - Size: rsf.offset, - Mode: 0644, - ModTime: rsf.modTime, - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "rotate-stream", - Content: map[string]string{ - "total_written": fmt.Sprintf("%d", rsf.offset), - "active_readers": fmt.Sprintf("%d", len(rsf.readers)), - "current_file_size": fmt.Sprintf("%d", rsf.currentFileSize), - "rotation_file_idx": fmt.Sprintf("%d", rsf.fileIndex), - "rotation_threshold": formatSize(rsf.config.RotationSize), - }, - }, - } -} - -// StreamRotateFS implements FileSystem interface for rotating streaming files -type StreamRotateFS struct { - streams map[string]*RotateStreamFile - mu sync.RWMutex - channelBuffer int - ringSize int - rotationCfg RotationConfig - pluginName string - parentFS filesystem.FileSystem // Reference to parent agfs filesystem -} - -// NewStreamRotateFS creates a new StreamRotateFS -func NewStreamRotateFS(channelBuffer int, ringSize int, rotationCfg RotationConfig, parentFS filesystem.FileSystem) *StreamRotateFS { - if channelBuffer <= 0 { - channelBuffer = 100 - } - if ringSize <= 0 { - ringSize = 100 - } - return &StreamRotateFS{ - streams: make(map[string]*RotateStreamFile), - channelBuffer: channelBuffer, - ringSize: ringSize, - rotationCfg: rotationCfg, - pluginName: PluginName, - parentFS: parentFS, - } -} - -func (srf *StreamRotateFS) Create(path string) error { - // Prevent creating a stream named README (reserved for documentation) - if path == "/README" { - return fmt.Errorf("cannot create stream named README: reserved for documentation") - } - - srf.mu.Lock() - defer srf.mu.Unlock() - - if _, exists := srf.streams[path]; exists { - return fmt.Errorf("stream already exists: %s", path) - } - - srf.streams[path] = NewRotateStreamFile(path, srf.channelBuffer, srf.ringSize, srf.rotationCfg, srf.parentFS) - return nil -} - -func (srf *StreamRotateFS) Mkdir(path string, perm uint32) error { - return fmt.Errorf("streamrotatefs does not support directories") -} - -func (srf *StreamRotateFS) Remove(path string) error { - srf.mu.Lock() - defer srf.mu.Unlock() - - stream, exists := srf.streams[path] - if !exists { - return fmt.Errorf("stream not found: %s", path) - } - - stream.Close() - delete(srf.streams, path) - return nil -} - -func (srf *StreamRotateFS) RemoveAll(path string) error { - return srf.Remove(path) -} - -func (srf *StreamRotateFS) Read(path string, offset int64, size int64) ([]byte, error) { - if path == "/README" { - content := []byte(getReadme()) - return plugin.ApplyRangeRead(content, offset, size) - } - - return nil, fmt.Errorf("use stream mode for reading stream files") -} - -func (srf *StreamRotateFS) Write(path string, data []byte, offset int64, flags filesystem.WriteFlag) (int64, error) { - // Prevent writing to README (reserved for documentation) - if path == "/README" { - return 0, fmt.Errorf("cannot write to README: reserved for documentation, use regular read mode") - } - - srf.mu.Lock() - stream, exists := srf.streams[path] - if !exists { - stream = NewRotateStreamFile(path, srf.channelBuffer, srf.ringSize, srf.rotationCfg, srf.parentFS) - srf.streams[path] = stream - } - srf.mu.Unlock() - - // StreamRotateFS is append-only (broadcast), offset is ignored - err := stream.Write(data) - if err != nil { - return 0, err - } - - return int64(len(data)), nil -} - -func (srf *StreamRotateFS) ReadDir(path string) ([]filesystem.FileInfo, error) { - if path != "/" { - return nil, fmt.Errorf("not a directory: %s", path) - } - - srf.mu.RLock() - defer srf.mu.RUnlock() - - readme := filesystem.FileInfo{ - Name: "README", - Size: int64(len(getReadme())), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - } - - files := []filesystem.FileInfo{readme} - for path, stream := range srf.streams { - // Skip README stream if it somehow exists (shouldn't happen with Create check) - if path == "/README" { - continue - } - files = append(files, stream.GetInfo()) - } - - return files, nil -} - -func (srf *StreamRotateFS) Stat(path string) (*filesystem.FileInfo, error) { - if path == "/" { - info := &filesystem.FileInfo{ - Name: "/", - Size: 0, - Mode: 0755, - ModTime: time.Now(), - IsDir: true, - Meta: filesystem.MetaData{ - Name: PluginName, - }, - } - return info, nil - } - - if path == "/README" { - readme := getReadme() - info := &filesystem.FileInfo{ - Name: "README", - Size: int64(len(readme)), - Mode: 0444, - ModTime: time.Now(), - IsDir: false, - Meta: filesystem.MetaData{ - Name: PluginName, - Type: "doc", - }, - } - return info, nil - } - - srf.mu.RLock() - stream, exists := srf.streams[path] - srf.mu.RUnlock() - - if !exists { - return nil, fmt.Errorf("stream not found: %s", path) - } - - info := stream.GetInfo() - return &info, nil -} - -func (srf *StreamRotateFS) Rename(oldPath, newPath string) error { - return fmt.Errorf("streamrotatefs does not support rename") -} - -func (srf *StreamRotateFS) Chmod(path string, mode uint32) error { - return fmt.Errorf("streamrotatefs does not support chmod") -} - -func (srf *StreamRotateFS) Open(path string) (io.ReadCloser, error) { - if path == "/README" { - return io.NopCloser(bytes.NewReader([]byte(getReadme()))), nil - } - return nil, fmt.Errorf("use stream mode for reading stream files") -} - -func (srf *StreamRotateFS) OpenWrite(path string) (io.WriteCloser, error) { - return &streamWriter{srf: srf, path: path}, nil -} - -// OpenStream implements filesystem.Streamer interface -func (srf *StreamRotateFS) OpenStream(path string) (filesystem.StreamReader, error) { - // README is not a streamable file - if path == "/README" { - return nil, fmt.Errorf("README is not a streamable file, use regular read mode") - } - - srf.mu.Lock() - stream, exists := srf.streams[path] - if !exists { - stream = NewRotateStreamFile(path, srf.channelBuffer, srf.ringSize, srf.rotationCfg, srf.parentFS) - srf.streams[path] = stream - log.Infof("[streamrotatefs] Auto-created stream %s for reader", path) - } - srf.mu.Unlock() - - readerID, ch := stream.RegisterReader() - log.Infof("[streamrotatefs] Opened stream %s with reader %s", path, readerID) - - return &streamReader{ - rsf: stream, - readerID: readerID, - ch: ch, - }, nil -} - -// SetParentFS sets the parent filesystem reference -// This must be called after the plugin is initialized to enable agfs output -func (srf *StreamRotateFS) SetParentFS(fs filesystem.FileSystem) { - srf.mu.Lock() - defer srf.mu.Unlock() - srf.parentFS = fs - log.Infof("[streamrotatefs] Parent filesystem set, agfs output enabled") -} - -type streamWriter struct { - srf *StreamRotateFS - path string -} - -func (sw *streamWriter) Write(p []byte) (n int, err error) { - _, err = sw.srf.Write(sw.path, p, -1, filesystem.WriteFlagAppend) - if err != nil { - return 0, err - } - return len(p), nil -} - -func (sw *streamWriter) Close() error { - return nil -} - -// StreamRotateFSPlugin wraps StreamRotateFS as a plugin -type StreamRotateFSPlugin struct { - fs *StreamRotateFS - channelBuffer int - ringSize int - rotationCfg RotationConfig -} - -// NewStreamRotateFSPlugin creates a new StreamRotateFS plugin -func NewStreamRotateFSPlugin() *StreamRotateFSPlugin { - return &StreamRotateFSPlugin{ - channelBuffer: 100, - ringSize: 100, - rotationCfg: RotationConfig{ - RotationInterval: 0, - RotationSize: 100 * 1024 * 1024, // Default: 100MB - OutputPath: "/localfs/rotated_files", - FilenamePattern: "{channel}_{timestamp}.dat", - }, - } -} - -func (p *StreamRotateFSPlugin) Name() string { - return PluginName -} - -func (p *StreamRotateFSPlugin) Validate(cfg map[string]interface{}) error { - allowedKeys := []string{ - "channel_buffer_size", "ring_buffer_size", - "rotation_interval", "rotation_size", - "output_path", "filename_pattern", - "mount_path", - } - if err := config.ValidateOnlyKnownKeys(cfg, allowedKeys); err != nil { - return err - } - - // Validate rotation_interval if provided - if val, exists := cfg["rotation_interval"]; exists { - if strVal, ok := val.(string); ok { - if _, err := parseDuration(strVal); err != nil { - return fmt.Errorf("invalid rotation_interval: %w", err) - } - } else { - return fmt.Errorf("rotation_interval must be a duration string (e.g., '5m', '1h')") - } - } - - // Validate rotation_size if provided - if val, exists := cfg["rotation_size"]; exists { - switch v := val.(type) { - case string: - if _, err := config.ParseSize(v); err != nil { - return fmt.Errorf("invalid rotation_size: %w", err) - } - case int, int64, float64: - // Valid numeric types - default: - return fmt.Errorf("rotation_size must be a size string (e.g., '100MB') or number") - } - } - - // Validate output_path is required and must be an agfs path - if val, exists := cfg["output_path"]; !exists { - return fmt.Errorf("output_path is required") - } else if strVal, ok := val.(string); !ok { - return fmt.Errorf("output_path must be a string") - } else if !strings.HasPrefix(strVal, "/") { - return fmt.Errorf("output_path must be an agfs path (must start with /), e.g., /s3fs/bucket or /localfs/data") - } - - return nil -} - -func (p *StreamRotateFSPlugin) Initialize(cfg map[string]interface{}) error { - const defaultChunkSize = 64 * 1024 - - // Parse channel buffer size - channelBufferBytes := int64(6 * 1024 * 1024) - if val, ok := cfg["channel_buffer_size"]; ok { - if parsed, err := config.ParseSize(fmt.Sprintf("%v", val)); err == nil { - channelBufferBytes = parsed - } - } - - // Parse ring buffer size - ringBufferBytes := int64(6 * 1024 * 1024) - if val, ok := cfg["ring_buffer_size"]; ok { - if parsed, err := config.ParseSize(fmt.Sprintf("%v", val)); err == nil { - ringBufferBytes = parsed - } - } - - // Parse rotation interval - p.rotationCfg.RotationInterval = 0 - if val, ok := cfg["rotation_interval"].(string); ok { - if duration, err := parseDuration(val); err == nil { - p.rotationCfg.RotationInterval = duration - } - } - - // Parse rotation size - p.rotationCfg.RotationSize = 100 * 1024 * 1024 // Default: 100MB - if val, ok := cfg["rotation_size"]; ok { - if parsed, err := config.ParseSize(fmt.Sprintf("%v", val)); err == nil { - p.rotationCfg.RotationSize = parsed - } - } - - // Parse output path (required, must be agfs path) - if val, ok := cfg["output_path"].(string); ok { - p.rotationCfg.OutputPath = val - } else { - return fmt.Errorf("output_path is required") - } - - // Parse filename pattern - p.rotationCfg.FilenamePattern = "{channel}_{timestamp}.dat" - if val, ok := cfg["filename_pattern"].(string); ok { - p.rotationCfg.FilenamePattern = val - } - - // Convert bytes to chunks - p.channelBuffer = int(channelBufferBytes / defaultChunkSize) - if p.channelBuffer < 1 { - p.channelBuffer = 1 - } - - p.ringSize = int(ringBufferBytes / defaultChunkSize) - if p.ringSize < 1 { - p.ringSize = 1 - } - - // Create filesystem (parentFS will be set later via SetParentFS) - p.fs = NewStreamRotateFS(p.channelBuffer, p.ringSize, p.rotationCfg, nil) - - log.Infof("[streamrotatefs] Initialized with rotation_size=%s, rotation_interval=%s, output_path=%s, pattern=%s", - formatSize(p.rotationCfg.RotationSize), - p.rotationCfg.RotationInterval, - p.rotationCfg.OutputPath, - p.rotationCfg.FilenamePattern) - - return nil -} - -// SetParentFileSystem sets the parent filesystem for agfs output -// This should be called by the mount system after initialization -func (p *StreamRotateFSPlugin) SetParentFileSystem(fs filesystem.FileSystem) { - if p.fs != nil { - p.fs.SetParentFS(fs) - } -} - -func (p *StreamRotateFSPlugin) GetFileSystem() filesystem.FileSystem { - return p.fs -} - -func (p *StreamRotateFSPlugin) GetReadme() string { - return getReadme() -} - -func (p *StreamRotateFSPlugin) GetConfigParams() []plugin.ConfigParameter { - return []plugin.ConfigParameter{ - { - Name: "channel_buffer_size", - Type: "string", - Required: false, - Default: "6MB", - Description: "Channel buffer size per reader (e.g., '512KB', '6MB')", - }, - { - Name: "ring_buffer_size", - Type: "string", - Required: false, - Default: "6MB", - Description: "Ring buffer size for historical data (e.g., '1MB', '6MB')", - }, - { - Name: "rotation_interval", - Type: "string", - Required: false, - Default: "", - Description: "Time-based rotation interval (e.g., '5m', '1h', '24h'). Empty = disabled", - }, - { - Name: "rotation_size", - Type: "string", - Required: false, - Default: "100MB", - Description: "Size-based rotation threshold (e.g., '100MB', '1GB')", - }, - { - Name: "output_path", - Type: "string", - Required: true, - Default: "/localfs/rotated_files", - Description: "Output agfs path (e.g., /s3fs/bucket or /localfs/data) for rotated files", - }, - { - Name: "filename_pattern", - Type: "string", - Required: false, - Default: "{channel}_{timestamp}.dat", - Description: "Filename pattern. Variables: {channel}, {timestamp}, {date}, {time}, {datetime}, {index}", - }, - } -} - -func (p *StreamRotateFSPlugin) Shutdown() error { - return nil -} - -func getReadme() string { - return `StreamRotateFS Plugin - Rotating Streaming File System - -This plugin extends StreamFS with automatic file rotation support. -Data is streamed to readers while being saved to rotating files on local filesystem. - -FEATURES: - - All StreamFS features (multiple readers/writers, ring buffer, fanout) - - Time-based rotation: Rotate files at specified intervals (e.g., every 5 minutes) - - Size-based rotation: Rotate files when reaching size threshold (e.g., 100MB) - - Configurable output path: Save to any agfs mount point - - Customizable filename pattern: Use variables for dynamic naming - - Concurrent operation: Rotation doesn't interrupt streaming - -ROTATION TRIGGERS: - - Time interval: Files rotate after specified duration (rotation_interval) - - File size: Files rotate when reaching size threshold (rotation_size) - - Both can be enabled simultaneously (triggers on first condition met) - -FILENAME PATTERN VARIABLES: - {channel} - Channel/stream name - {timestamp} - Unix timestamp (seconds) - {date} - Date in YYYYMMDD format - {time} - Time in HHMMSS format - {datetime} - Date and time in YYYYMMDD_HHMMSS format - {index} - Rotation file index (6-digit zero-padded) - -USAGE EXAMPLES: - - Write to rotating stream: - cat video.mp4 | agfs write --stream /streamrotatefs/channel1 - - Read from stream (live): - agfs cat --stream /streamrotatefs/channel1 | ffplay - - - List rotated files: - agfs ls /s3fs/bucket/streams/ - agfs ls /localfs/data/ - -CONFIGURATION: - - [plugins.streamrotatefs] - enabled = true - path = "/streamrotatefs" - - [plugins.streamrotatefs.config] - # Stream buffer settings (same as streamfs) - channel_buffer_size = "6MB" - ring_buffer_size = "6MB" - - # Rotation settings - rotation_interval = "5m" # Rotate every 5 minutes - rotation_size = "100MB" # Rotate at 100MB - - # Output path - must be an agfs path - output_path = "/s3fs/bucket/path" # Save to S3 via s3fs - # OR - # output_path = "/localfs/data" # Save via localfs - - filename_pattern = "{channel}_{datetime}_{index}.dat" - -CONFIGURATION EXAMPLES: - - Time-based rotation (every hour): - rotation_interval = "1h" - rotation_size = "" # Disabled - - Size-based rotation (100MB chunks): - rotation_interval = "" # Disabled - rotation_size = "100MB" - - Combined (whichever comes first): - rotation_interval = "10m" - rotation_size = "50MB" - -FILENAME PATTERN EXAMPLES: - - {channel}_{timestamp}.dat - → channel1_1702345678.dat - - {date}/{channel}_{time}.mp4 - → 20231207/channel1_143058.mp4 - - {channel}/segment_{index}.ts - → channel1/segment_000001.ts - -OUTPUT PATH: - - Must be an AGFS path (starts with /) - - Example: "/s3fs/bucket/path" - Save to S3 - - Example: "/localfs/data" - Save via localfs plugin - - Supports any mounted agfs filesystem - -IMPORTANT NOTES: - - output_path must be an agfs path (e.g., /s3fs/bucket or /localfs/data) - - The target mount point must be already mounted and writable - - Parent directories will be created automatically if the filesystem supports it - - Stream continues uninterrupted during rotation - - Old rotation files are not automatically deleted - - Readers receive live data regardless of rotation - - File index increments with each rotation - -## License - -Apache License 2.0 -` -} - -// Ensure StreamRotateFSPlugin implements ServicePlugin -var _ plugin.ServicePlugin = (*StreamRotateFSPlugin)(nil) diff --git a/third_party/agfs/agfs-shell/.gitignore b/third_party/agfs/agfs-shell/.gitignore deleted file mode 100644 index 78fdef6c0..000000000 --- a/third_party/agfs/agfs-shell/.gitignore +++ /dev/null @@ -1,38 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg - -# Virtual environments -.venv/ -venv/ -ENV/ -env/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db diff --git a/third_party/agfs/agfs-shell/Makefile b/third_party/agfs/agfs-shell/Makefile deleted file mode 100644 index 718f84a0f..000000000 --- a/third_party/agfs/agfs-shell/Makefile +++ /dev/null @@ -1,54 +0,0 @@ -.PHONY: build install clean uninstall test help - -# Installation directory (can be overridden) -INSTALL_DIR ?= $(HOME)/.local/agfs-shell -BIN_LINK_DIR ?= $(HOME)/.local/bin - -help: - @echo "agfs-shell build and installation" - @echo "" - @echo "Available targets:" - @echo " make build - Build portable distribution with uv" - @echo " make install - Install to $(INSTALL_DIR)" - @echo " make uninstall - Remove installation" - @echo " make test - Run tests with pytest" - @echo " make clean - Clean build artifacts" - @echo "" - @echo "Override installation directory:" - @echo " make install INSTALL_DIR=/opt/agfs-shell" - @echo "" - @echo "Requirements:" - @echo " - Python 3.8+" - @echo " - uv package manager" - -build: - @echo "Building portable agfs-shell distribution..." - @python3 build.py - -test: - @echo "Running tests with pytest..." - @uv run pytest tests/ - -install: clean build - @echo "Installing agfs-shell to $(INSTALL_DIR)..." - @rm -rf $(INSTALL_DIR) - @mkdir -p $(INSTALL_DIR) - @cp -r dist/agfs-shell-portable/* $(INSTALL_DIR)/ - @mkdir -p $(BIN_LINK_DIR) - @ln -sf $(INSTALL_DIR)/agfs-shell $(BIN_LINK_DIR)/agfs-shell - @echo "✓ Installed successfully" - @echo " Install dir: $(INSTALL_DIR)" - @echo " Symlink: $(BIN_LINK_DIR)/agfs-shell" - @echo "" - @echo "Run 'agfs-shell --help' to get started" - -uninstall: - @echo "Removing agfs-shell installation..." - @rm -rf $(INSTALL_DIR) - @rm -f $(BIN_LINK_DIR)/agfs-shell - @echo "✓ Uninstalled successfully" - -clean: - @echo "Cleaning build artifacts..." - @rm -rf build dist *.spec - @echo "✓ Clean complete" diff --git a/third_party/agfs/agfs-shell/README.md b/third_party/agfs/agfs-shell/README.md deleted file mode 100644 index 74c2f80e2..000000000 --- a/third_party/agfs/agfs-shell/README.md +++ /dev/null @@ -1,1854 +0,0 @@ -# agfs-shell - -An experimental shell implementation with Unix-style pipeline support and **AGFS integration**, written in pure Python. - -## Table of Contents - -- [Overview](#overview) -- [Features](#features) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Shell Syntax Reference](#shell-syntax-reference) - - [Comments](#comments) - - [Pipelines](#pipelines) - - [Redirection](#redirection) - - [Variables](#variables) - - [Arithmetic Expansion](#arithmetic-expansion) - - [Command Substitution](#command-substitution) - - [Glob Patterns](#glob-patterns) - - [Control Flow](#control-flow) - - [Functions](#functions) - - [Heredoc](#heredoc) -- [Built-in Commands](#built-in-commands) - - [File System Commands](#file-system-commands) - - [Text Processing](#text-processing) - - [Environment Variables](#environment-variables) - - [Conditional Testing](#conditional-testing) - - [Control Flow Commands](#control-flow-commands) - - [AGFS Management](#agfs-management) - - [Utility Commands](#utility-commands) - - [AI Integration](#ai-integration) -- [Script Files](#script-files) -- [Interactive Features](#interactive-features) -- [Complex Examples](#complex-examples) -- [Architecture](#architecture) -- [Testing](#testing) - -## Overview - -agfs-shell is a lightweight, educational shell that demonstrates Unix pipeline concepts while integrating with the AGFS (Aggregated File System) server. All file operations go through AGFS, allowing you to work with multiple backend filesystems (local, S3, SQL, etc.) through a unified interface. - -**Key Features:** -- Unix-style pipelines and redirection -- Full scripting support with control flow -- User-defined functions with local variables (with some limitations) -- AGFS integration for distributed file operations -- Tab completion and command history -- AI-powered command (llm integration) -- Pure Python implementation (no subprocess for builtins) - -**Note:** This is an educational shell implementation. Advanced features like recursive functions require a full call stack implementation (future work). - -## Features - -### Core Shell Features -- **Pipelines**: Chain commands with `|` operator -- **I/O Redirection**: `<`, `>`, `>>`, `2>`, `2>>` -- **Heredoc**: Multi-line input with `<<` (supports variable expansion) -- **Variables**: Assignment, expansion, special variables (`$?`, `$1`, `$@`, etc.) -- **Arithmetic**: `$((expression))` for calculations -- **Command Substitution**: `$(command)` or backticks -- **Glob Expansion**: `*.txt`, `file?.dat`, `[abc]` -- **Control Flow**: `if/then/elif/else/fi` and `for/in/do/done` -- **Functions**: User-defined functions with parameters, local variables, and return values (non-recursive) -- **Comments**: `#` and `//` style comments - -### Built-in Commands (42+) -- **File Operations**: cd, pwd, ls, tree, cat, mkdir, touch, rm, mv, stat, cp, upload, download -- **Text Processing**: echo, grep, jq, wc, head, tail, tee, sort, uniq, tr, rev, cut -- **Path Utilities**: basename, dirname -- **Variables**: export, env, unset, local -- **Testing**: test, [ ] -- **Control Flow**: break, continue, exit, return, true, false -- **Utilities**: sleep, date, plugins, mount, help -- **AI**: llm (LLM integration) -- **Operators**: `&&` (AND), `||` (OR) for conditional command execution - -### Interactive Features -- **Tab Completion**: Commands and file paths (AGFS-aware) -- **Command History**: Persistent across sessions (`~/.agfs_shell_history`) -- **Multiline Editing**: Backslash continuation, quote matching -- **Rich Output**: Colorized formatting with Rich library -- **Dynamic Prompt**: Shows current directory - -### AGFS Integration -- **Unified Interface**: Work with multiple filesystems through AGFS -- **File Transfer**: Upload/download between local and AGFS -- **Streaming I/O**: Memory-efficient processing (8KB chunks) -- **Cross-filesystem Operations**: Copy between different backends - -## Prerequisites - -**AGFS Server must be running!** - -```bash -# Option 1: Run from source -cd agfs-server -go run main.go - -# Option 2: Use Docker -docker run -p 8080:8080 c4pt0r/agfs-server:latest -``` - -## Installation - -```bash -cd agfs-shell -uv sync -``` - -## Quick Start - -### Interactive Mode - -```bash -uv run agfs-shell - -agfs:/> echo "Hello, World!" > /local/tmp/hello.txt -agfs:/> cat /local/tmp/hello.txt -Hello, World! - -agfs:/> ls /local/tmp | grep txt -hello.txt - -agfs:/> for i in 1 2 3; do -> echo "Count: $i" -> done -Count: 1 -Count: 2 -Count: 3 -``` - -### Execute Command String - -```bash -# Using -c flag -uv run agfs-shell -c "echo 'test' > /local/tmp/test.txt" - -# With pipeline -uv run agfs-shell -c "cat /local/tmp/data.txt | sort | uniq > /local/tmp/sorted.txt" -``` - -### Execute Script File - -Create a script file with `.as` extension: - -```bash -cat > example.as << 'EOF' -#!/usr/bin/env uv run agfs-shell - -# Count files in directory -count=0 -for file in /local/tmp/*; do - count=$((count + 1)) - echo "File $count: $file" -done - -echo "Total files: $count" -EOF - -chmod +x example.as -./example.as -``` - -## Shell Syntax Reference - -### Comments - -```bash -# This is a comment (recommended) -// This is also a comment (C-style, also supported) - -echo "Hello" # Inline comment -echo "World" // Inline comment works too -``` - -### Pipelines - -```bash -# Basic pipeline -command1 | command2 | command3 - -# Examples -cat /local/tmp/data.txt | grep "error" | wc -l -ls /local/tmp | sort | head -n 10 -``` - -### Redirection - -```bash -# Input redirection -command < input.txt - -# Output redirection -command > output.txt # Overwrite -command >> output.txt # Append - -# Error redirection -command 2> errors.log # Redirect stderr -command 2>> errors.log # Append stderr - -# Combined -command < input.txt > output.txt 2> errors.log -``` - -### Variables - -```bash -# Assignment -NAME="Alice" -COUNT=10 -PATH=/local/data - -# Expansion -echo $NAME # Simple expansion -echo ${NAME} # Braced expansion (preferred) -echo "Hello, $NAME!" # In double quotes - -# Special variables -echo $? # Exit code of last command -echo $0 # Script name -echo $1 $2 # Script arguments -echo $# # Number of arguments -echo $@ # All arguments - -# Environment variables -export DATABASE_URL="postgres://localhost/mydb" -env | grep DATABASE -unset DATABASE_URL -``` - -### Arithmetic Expansion - -```bash -# Basic arithmetic -result=$((5 + 3)) -echo $result # 8 - -# With variables -count=10 -count=$((count + 1)) -echo $count # 11 - -# Complex expressions -x=5 -y=3 -result=$(( (x + y) * 2 )) -echo $result # 16 - -# In loops -for i in 1 2 3 4 5; do - doubled=$((i * 2)) - echo "$i * 2 = $doubled" -done -``` - -### Command Substitution - -```bash -# Using $() syntax (recommended) -current_dir=$(pwd) -file_count=$(ls /local/tmp | wc -l) -today=$(date "+%Y-%m-%d") - -# Using backticks (also works) -files=`ls /local/tmp` - -# In strings -echo "There are $(ls /local/tmp | wc -l) files in the directory" -``` - -### Glob Patterns - -```bash -# Wildcard matching -*.txt # All .txt files -file?.dat # file followed by any single character -test[123].log # test1.log, test2.log, or test3.log -file[a-z].txt # file with single letter a-z - -# Examples -cat /local/tmp/*.txt # Concatenate all text files -rm /local/tmp/temp_* # Remove all temp_ files -for file in /local/tmp/data_[0-9]*.json; do - echo "Processing $file" -done -``` - -### Control Flow - -**If Statements:** - -```bash -# Basic if -if [ -f /local/tmp/file.txt ]; then - echo "File exists" -fi - -# If-else -if [ -d /local/tmp/mydir ]; then - echo "Directory exists" -else - echo "Directory not found" -fi - -# If-elif-else -if [ "$STATUS" = "running" ]; then - echo "Service is running" -elif [ "$STATUS" = "stopped" ]; then - echo "Service is stopped" -else - echo "Unknown status" -fi - -# Single line -if [ -f file.txt ]; then cat file.txt; fi -``` - -**For Loops:** - -```bash -# Basic loop -for i in 1 2 3 4 5; do - echo "Number: $i" -done - -# Loop over files -for file in /local/tmp/*.txt; do - echo "Processing $file" - cat $file | wc -l -done - -# Loop with command substitution -for user in $(cat /local/tmp/users.txt); do - echo "User: $user" -done - -# Nested loops -for dir in /local/tmp/projects/*; do - echo "Project: $(basename $dir)" - for file in $dir/*.txt; do - echo " File: $(basename $file)" - done -done -``` - -**Loop Control:** - -```bash -# Break - exit loop early -for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then - break - fi - echo $i -done -# Output: 1, 2 - -# Continue - skip to next iteration -for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then - continue - fi - echo $i -done -# Output: 1, 2, 4, 5 -``` - -**Conditional Execution:** - -```bash -# && operator - execute second command only if first succeeds -test -f /local/tmp/file.txt && echo "File exists" - -# || operator - execute second command only if first fails -test -f /local/tmp/missing.txt || echo "File not found" - -# Combining && and || -mkdir /local/tmp/data && echo "Created" || echo "Failed" - -# Short-circuit evaluation -false && echo "Not executed" -true || echo "Not executed" - -# Using true/false commands -if true; then - echo "Always runs" -fi - -if false; then - echo "Never runs" -fi - -# Practical example: fallback chain -command1 || command2 || command3 || echo "All failed" -``` - -### Functions - -**Function Definition:** - -```bash -# Syntax 1: function_name() { ... } -greet() { - echo "Hello, $1!" -} - -# Syntax 2: function keyword -function greet { - echo "Hello, $1!" -} - -# Single-line syntax -greet() { echo "Hello, $1!"; } -``` - -**Function Calls:** - -```bash -# Direct function calls (fully supported) -greet Alice # $1 = Alice -greet Bob Charlie # $1 = Bob, $2 = Charlie - -# Functions can call other functions -outer() { - echo "Calling inner..." - inner -} - -inner() { - echo "Inside inner function" -} - -outer -``` - -**Local Variables:** - -```bash -counter() { - local count=0 # Declare local variable - count=$((count + 1)) - echo $count -} - -# Local variables don't affect global scope -x=100 -test_scope() { - local x=10 - echo "Inside: $x" # Prints: Inside: 10 -} -test_scope -echo "Outside: $x" # Prints: Outside: 100 -``` - -**Return Values:** - -```bash -is_positive() { - if [ $1 -gt 0 ]; then - return 0 # Success - else - return 1 # Failure - fi -} - -is_positive 5 -echo "Exit code: $?" # Prints: Exit code: 0 -``` - -**Positional Parameters:** - -```bash -show_args() { - echo "Function: $0" # Function name - echo "Arg count: $#" # Number of arguments - echo "All args: $@" # All arguments - echo "First: $1" # First argument - echo "Second: $2" # Second argument -} - -show_args apple banana cherry -``` - -**Functions with Control Flow:** - -```bash -# Functions with if/else -check_file() { - if [ -f $1 ]; then - echo "File exists: $1" - return 0 - else - echo "File not found: $1" - return 1 - fi -} - -check_file /local/tmp/test.txt - -# Functions with loops -sum_numbers() { - local total=0 - for num in $@; do - total=$((total + num)) - done - echo "Total: $total" -} - -sum_numbers 1 2 3 4 5 # Total: 15 - -# Functions with arithmetic -calculate() { - local a=$1 - local b=$2 - local sum=$((a + b)) - local product=$((a * b)) - echo "Sum: $sum, Product: $product" -} - -calculate 5 3 # Sum: 8, Product: 15 -``` - -**Known Limitations:** - -```bash -# ⚠️ Command substitution with functions has limited support -# Simple cases work, but complex scenarios may not capture output correctly - -# ✓ This works -simple_func() { echo "hello"; } -result=$(simple_func) # result="hello" - -# ✗ Recursive functions don't work (requires call stack implementation) -factorial() { - if [ $1 -le 1 ]; then - echo 1 - else - local prev=$(factorial $(($1 - 1))) # ⚠️ Recursion not supported - echo $(($1 * prev)) - fi -} - -# Workaround: Use iterative approaches instead of recursion -``` - -### Heredoc - -```bash -# Variable expansion (default) -cat << EOF > /local/tmp/config.txt -Application: $APP_NAME -Version: $VERSION -Date: $(date) -EOF - -# Literal mode (no expansion) -cat << 'EOF' > /local/tmp/script.sh -#!/bin/bash -echo "Price: $100" -VAR="literal" -EOF - -# With indentation -cat <<- EOF - Indented text - Multiple lines -EOF -``` - -## Built-in Commands - -### File System Commands - -All file operations use AGFS paths (e.g., `/local/`, `/s3fs/`, `/sqlfs/`). - -#### cd [path] -Change current directory. - -```bash -cd /local/mydir # Absolute path -cd mydir # Relative path -cd .. # Parent directory -cd # Home directory (/) -``` - -#### pwd -Print current working directory. - -```bash -pwd # /local/mydir -``` - -#### ls [-l] [path] -List directory contents. - -```bash -ls # List current directory -ls /local # List specific directory -ls -l # Long format with details -ls -l /local/*.txt # List with glob pattern -``` - -#### tree [OPTIONS] [path] -Display directory tree structure. - -```bash -tree /local # Show tree -tree -L 2 /local # Max depth 2 -tree -d /local # Directories only -tree -a /local # Show hidden files -tree -h /local # Human-readable sizes -``` - -#### cat [file...] -Concatenate and print files or stdin. - -```bash -cat /local/tmp/file.txt # Display file -cat file1.txt file2.txt # Concatenate multiple -cat # Read from stdin -echo "hello" | cat # Via pipeline -``` - -#### mkdir path -Create directory. - -```bash -mkdir /local/tmp/newdir - -# Note: mkdir does not support -p flag for creating parent directories -# Create directories one by one: -mkdir /local/tmp/a -mkdir /local/tmp/a/b -mkdir /local/tmp/a/b/c -``` - -#### touch path -Create empty file or update timestamp. - -```bash -touch /local/tmp/newfile.txt -touch file1.txt file2.txt file3.txt -``` - -#### rm [-r] path -Remove file or directory. - -```bash -rm /local/tmp/file.txt # Remove file -rm -r /local/tmp/mydir # Remove directory recursively -``` - -#### mv source dest -Move or rename files/directories. - -```bash -mv /local/tmp/old.txt /local/tmp/new.txt # Rename -mv /local/tmp/file.txt /local/tmp/backup/ # Move to directory -mv local:~/file.txt /local/tmp/ # From local filesystem to AGFS -mv /local/tmp/file.txt local:~/ # From AGFS to local filesystem -``` - -#### stat path -Display file status and metadata. - -```bash -stat /local/tmp/file.txt -``` - -#### cp [-r] source dest -Copy files between local filesystem and AGFS. - -```bash -cp /local/tmp/file.txt /local/tmp/backup/file.txt # Within AGFS -cp local:~/data.csv /local/tmp/imports/data.csv # Local to AGFS -cp /local/tmp/report.txt local:~/Desktop/report.txt # AGFS to local -cp -r /local/tmp/mydir /local/tmp/backup/mydir # Recursive copy -``` - -#### upload [-r] local_path agfs_path -Upload files/directories from local to AGFS. - -```bash -upload ~/Documents/report.pdf /local/tmp/backup/ -upload -r ~/Projects/myapp /local/tmp/projects/ -``` - -#### download [-r] agfs_path local_path -Download files/directories from AGFS to local. - -```bash -download /local/tmp/data.json ~/Downloads/ -download -r /local/tmp/logs ~/backup/logs/ -``` - -### Text Processing - -#### echo [args...] -Print arguments to stdout. - -```bash -echo "Hello, World!" -echo -n "No newline" -echo $HOME -``` - -#### grep [OPTIONS] PATTERN [files] -Search for patterns in text. - -```bash -grep "error" /local/tmp/app.log # Basic search -grep -i "ERROR" /local/tmp/app.log # Case-insensitive -grep -n "function" /local/tmp/code.py # Show line numbers -grep -c "TODO" /local/tmp/*.py # Count matches -grep -v "debug" /local/tmp/app.log # Invert match (exclude) -grep -l "import" /local/tmp/*.py # Show filenames only -grep "^error" /local/tmp/app.log # Lines starting with 'error' - -# Multiple files -grep "pattern" file1.txt file2.txt - -# With pipeline -cat /local/tmp/app.log | grep -i error | grep -v warning -``` - -#### jq filter [files] -Process JSON data. - -```bash -echo '{"name":"Alice","age":30}' | jq . # Pretty print -echo '{"name":"Alice"}' | jq '.name' # Extract field -cat data.json | jq '.items[]' # Array iteration -cat users.json | jq '.[] | select(.active == true)' # Filter -echo '[{"id":1},{"id":2}]' | jq '.[].id' # Map - -# Real-world example -cat /local/tmp/api_response.json | \ - jq '.users[] | select(.role == "admin") | .name' -``` - -#### wc [-l] [-w] [-c] -Count lines, words, and bytes. - -```bash -wc /local/tmp/file.txt # All counts -wc -l /local/tmp/file.txt # Lines only -wc -w /local/tmp/file.txt # Words only -cat /local/tmp/file.txt | wc -l # Via pipeline -``` - -#### head [-n count] -Output first N lines (default 10). - -```bash -head /local/tmp/file.txt # First 10 lines -head -n 5 /local/tmp/file.txt # First 5 lines -cat /local/tmp/file.txt | head -n 20 -``` - -#### tail [-n count] [-f] [-F] [file...] -Output last N lines (default 10). With `-f`, continuously follow the file and output new lines as they are appended. **Only works with AGFS files.** - -```bash -tail /local/tmp/file.txt # Last 10 lines -tail -n 5 /local/tmp/file.txt # Last 5 lines -tail -f /local/tmp/app.log # Follow mode: show last 10 lines, then continuously follow -tail -n 20 -f /local/tmp/app.log # Show last 20 lines, then follow -tail -F /streamfs/live.log # Stream mode: continuously read from stream -tail -F /streamrotate/metrics.log | grep ERROR # Filter stream data -cat /local/tmp/file.txt | tail -n 20 # Via pipeline -``` - -**Follow Mode (`-f`):** -- For regular files on localfs, s3fs, etc. -- First shows the last n lines, then follows new content -- Polls the file every 100ms for size changes -- Perfect for monitoring log files -- Press Ctrl+C to exit follow mode -- Uses efficient offset-based reading to only fetch new content - -**Stream Mode (`-F`):** -- **For filesystems that support stream API** (streamfs, streamrotatefs, etc.) -- Continuously reads from the stream without loading history -- Does NOT show historical data - only new data from the moment you start -- Uses streaming read to handle infinite streams efficiently -- Will error if the filesystem doesn't support streaming -- Perfect for real-time monitoring: `tail -F /streamfs/events.log` -- Works great with pipelines: `tail -F /streamrotate/app.log | grep ERROR` -- Press Ctrl+C to exit - -#### sort [-r] -Sort lines alphabetically. - -```bash -sort /local/tmp/file.txt # Ascending -sort -r /local/tmp/file.txt # Descending -cat /local/tmp/data.txt | sort | uniq -``` - -#### uniq -Remove duplicate adjacent lines. - -```bash -cat /local/tmp/file.txt | sort | uniq -``` - -#### tr set1 set2 -Translate characters. - -```bash -echo "hello" | tr 'h' 'H' # Hello -echo "HELLO" | tr 'A-Z' 'a-z' # hello -echo "hello world" | tr -d ' ' # helloworld -``` - -#### rev -Reverse each line character by character. - -```bash -echo "hello" | rev # olleh -cat /local/tmp/file.txt | rev -``` - -#### cut [OPTIONS] -Extract sections from lines. - -```bash -# Extract fields (CSV) -echo "John,Doe,30" | cut -f 1,2 -d ',' # John,Doe - -# Extract character positions -echo "Hello World" | cut -c 1-5 # Hello -echo "2024-01-15" | cut -c 6- # 01-15 - -# Process file -cat /local/tmp/data.csv | cut -f 2,4 -d ',' | sort -``` - -#### tee [-a] [file...] -Read from stdin and write to both stdout and files. **Only works with AGFS files.** - -```bash -# Output to screen and save to file -echo "Hello" | tee /local/tmp/output.txt - -# Multiple files -cat /local/tmp/app.log | grep ERROR | tee /local/tmp/errors.txt /s3fs/aws/logs/errors.log - -# Append mode -echo "New line" | tee -a /local/tmp/log.txt - -# Real-world pipeline example -tail -f /local/tmp/app.log | grep ERROR | tee /s3fs/aws/log/errors.log - -# With tail -F for streams -tail -F /streamfs/events.log | grep CRITICAL | tee /local/tmp/critical.log -``` - -**Options:** -- `-a`: Append to files instead of overwriting - -**Features:** -- **Streaming output**: Writes to stdout line-by-line with immediate flush for real-time display -- **Streaming write**: Uses iterator-based streaming write to AGFS (non-append mode) -- **Multiple files**: Can write to multiple destinations simultaneously -- Works seamlessly in pipelines with `tail -f` and `tail -F` - -**Use Cases:** -- Save pipeline output while still viewing it -- Log filtered data to multiple destinations -- Monitor logs in real-time while saving errors to a file - -### Path Utilities - -#### basename PATH [SUFFIX] -Extract filename from path. - -```bash -basename /local/path/to/file.txt # file.txt -basename /local/path/to/file.txt .txt # file - -# In scripts -for file in /local/tmp/*.csv; do - filename=$(basename $file .csv) - echo "Processing: $filename" -done -``` - -#### dirname PATH -Extract directory from path. - -```bash -dirname /local/tmp/path/to/file.txt # /local/tmp/path/to -dirname /local/tmp/file.txt # /local/tmp -dirname file.txt # . - -# In scripts -filepath=/local/tmp/data/file.txt -dirpath=$(dirname $filepath) -echo "Directory: $dirpath" -``` - -### Environment Variables - -#### export [VAR=value ...] -Set environment variables. - -```bash -export PATH=/usr/local/bin -export DATABASE_URL="postgres://localhost/mydb" -export LOG_LEVEL=debug - -# Multiple variables -export VAR1=value1 VAR2=value2 - -# View all -export -``` - -#### env -Display all environment variables. - -```bash -env # Show all -env | grep PATH # Filter -``` - -#### unset VAR [VAR ...] -Remove environment variables. - -```bash -unset DATABASE_URL -unset VAR1 VAR2 -``` - -### Conditional Testing - -#### test EXPRESSION -#### [ EXPRESSION ] - -Evaluate conditional expressions. - -**File Tests:** -```bash -[ -f /local/tmp/file.txt ] # File exists and is regular file -[ -d /local/tmp/mydir ] # Directory exists -[ -e /local/tmp/path ] # Path exists - -# Example -if [ -f /local/tmp/config.json ]; then - cat /local/tmp/config.json -fi -``` - -**String Tests:** -```bash -[ -z "$VAR" ] # String is empty -[ -n "$VAR" ] # String is not empty -[ "$A" = "$B" ] # Strings are equal -[ "$A" != "$B" ] # Strings are not equal - -# Example -if [ -z "$NAME" ]; then - echo "Name is empty" -fi -``` - -**Integer Tests:** -```bash -[ $A -eq $B ] # Equal -[ $A -ne $B ] # Not equal -[ $A -gt $B ] # Greater than -[ $A -lt $B ] # Less than -[ $A -ge $B ] # Greater or equal -[ $A -le $B ] # Less or equal - -# Example -if [ $COUNT -gt 10 ]; then - echo "Count exceeds limit" -fi -``` - -**Logical Operators:** -```bash -[ ! -f file.txt ] # NOT (negation) -[ -f file1.txt -a -f file2.txt ] # AND -[ -f file1.txt -o -f file2.txt ] # OR - -# Example -if [ -f /local/tmp/input.txt -a -f /local/tmp/output.txt ]; then - cat /local/tmp/input.txt > /local/tmp/output.txt -fi -``` - -### Control Flow Commands - -#### break -Exit from the innermost for loop. - -```bash -for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then - break - fi - echo $i -done -# Output: 1, 2 -``` - -#### continue -Skip to next iteration of loop. - -```bash -for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then - continue - fi - echo $i -done -# Output: 1, 2, 4, 5 -``` - -#### exit [n] -Exit script or shell with status code. - -```bash -exit # Exit with status 0 -exit 1 # Exit with status 1 -exit $? # Exit with last command's exit code - -# In script -if [ ! -f /local/tmp/required.txt ]; then - echo "Error: Required file not found" - exit 1 -fi -``` - -#### local VAR=value -Declare local variables (only valid within functions). - -```bash -myfunction() { - local counter=0 # Local to this function - local name=$1 # Local copy of first argument - counter=$((counter + 1)) - echo "Counter: $counter" -} - -myfunction test # Prints: Counter: 1 -# 'counter' variable doesn't exist outside the function -``` - -#### return [n] -Return from a function with an optional exit status. - -```bash -is_valid() { - if [ $1 -gt 0 ]; then - return 0 # Success - else - return 1 # Failure - fi -} - -is_valid 5 -if [ $? -eq 0 ]; then - echo "Valid number" -fi -``` - -### AGFS Management - -#### plugins -Manage AGFS plugins. - -```bash -plugins list - -# Output: -# Builtin Plugins: (15) -# localfs -> /local/tmp -# s3fs -> /etc, /s3fs/aws -# ... -# -# No external plugins loaded -``` - -#### mount [PLUGIN] [PATH] [OPTIONS] -Mount a new AGFS plugin. - -```bash -# Mount S3 filesystem -mount s3fs /s3-backup bucket=my-backup-bucket,region=us-west-2 - -# Mount SQL filesystem -mount sqlfs /sqldb connection=postgresql://localhost/mydb - -# Mount custom plugin -mount customfs /custom option1=value1,option2=value2 -``` - -### Utility Commands - -#### sleep seconds -Pause execution for specified seconds (supports decimals). - -```bash -sleep 1 # Sleep for 1 second -sleep 0.5 # Sleep for half a second -sleep 2.5 # Sleep for 2.5 seconds - -# In scripts -echo "Starting process..." -sleep 2 -echo "Process started" - -# Rate limiting -for i in 1 2 3 4 5; do - echo "Processing item $i" - sleep 1 -done -``` - -#### date [FORMAT] -Display current date and time. - -```bash -date # Wed Dec 6 10:23:45 PST 2025 -date "+%Y-%m-%d" # 2025-12-06 -date "+%Y-%m-%d %H:%M:%S" # 2025-12-06 10:23:45 -date "+%H:%M:%S" # 10:23:45 - -# Use in scripts -TIMESTAMP=$(date "+%Y%m%d_%H%M%S") -echo "Backup: backup_$TIMESTAMP.tar" - -LOG_DATE=$(date "+%Y-%m-%d") -echo "[$LOG_DATE] Process started" >> /local/tmp/log.txt -``` - -#### help -Show help message. - -```bash -help # Display comprehensive help -``` - -### AI Integration - -#### llm [OPTIONS] [PROMPT] -Interact with LLM models using AI integration. - -```bash -# Basic query -llm "What is the capital of France?" - -# Process text through pipeline -echo "Translate to Spanish: Hello World" | llm - -# Analyze file content -cat /local/code.py | llm "Explain what this code does" - -# Use specific model -llm -m gpt-4 "Complex question requiring advanced reasoning" - -# With system prompt -llm -s "You are a coding assistant" "How do I reverse a list in Python?" - -# Process JSON data -cat /local/data.json | llm "Summarize this data in 3 bullet points" - -# Analyze images (if model supports it) -cat /local/screenshot.png | llm -m gpt-4-vision "What's in this image?" - -# Debugging help -cat /local/error.log | llm "Analyze these errors and suggest fixes" -``` - -**Options:** -- `-m MODEL` - Specify model (default: gpt-4o-mini) -- `-s SYSTEM` - System prompt -- `-k KEY` - API key (overrides config) -- `-c CONFIG` - Config file path - -**Configuration:** -Create `/etc/llm.yaml` (in agfs) - -```yaml -models: - - name: gpt-4o-mini - provider: openai - api_key: sk-... - - name: gpt-4 - provider: openai - api_key: sk-... -``` - -## Script Files - -Script files use the `.as` extension (AGFS Shell scripts). - -### Creating Scripts - -```bash -cat > example.as << 'EOF' -#!/usr/bin/env uv run agfs-shell - -# Example script demonstrating AGFS shell features - -# Variables -SOURCE_DIR=/local/tmp/data -BACKUP_DIR=/local/tmp/backup -TIMESTAMP=$(date "+%Y%m%d_%H%M%S") - -# Create backup directory -mkdir $BACKUP_DIR - -# Process files -count=0 -for file in $SOURCE_DIR/*.txt; do - count=$((count + 1)) - - # Check file size - echo "Processing file $count: $file" - - # Backup file with timestamp - basename=$(basename $file .txt) - cp $file $BACKUP_DIR/${basename}_${TIMESTAMP}.txt -done - -echo "Backed up $count files to $BACKUP_DIR" -exit 0 -EOF - -chmod +x example.as -./example.as -``` - -### Script Arguments - -Scripts can access command-line arguments: - -```bash -cat > greet.as << 'EOF' -#!/usr/bin/env uv run agfs-shell - -# Access arguments -echo "Script name: $0" -echo "First argument: $1" -echo "Second argument: $2" -echo "Number of arguments: $#" -echo "All arguments: $@" - -# Use arguments -if [ $# -lt 1 ]; then - echo "Usage: $0 " - exit 1 -fi - -echo "Hello, $1!" -EOF - -chmod +x greet.as -./greet.as Alice Bob -``` - -### Advanced Script Example - -```bash -cat > backup_system.as << 'EOF' -#!/usr/bin/env uv run agfs-shell - -# Advanced backup script with error handling - -# Configuration -BACKUP_ROOT=/local/tmp/backups -SOURCE_DIRS="/local/tmp/data /local/tmp/config /local/tmp/logs" -DATE=$(date "+%Y-%m-%d") -BACKUP_DIR=$BACKUP_ROOT/$DATE -ERROR_LOG=$BACKUP_DIR/errors.log - -# Create backup directory -mkdir $BACKUP_ROOT -mkdir $BACKUP_DIR - -# Initialize error log -echo "Backup started at $(date)" > $ERROR_LOG - -# Backup function simulation with loop -backup_count=0 -error_count=0 - -for src in $SOURCE_DIRS; do - if [ -d $src ]; then - echo "Backing up $src..." | tee -a $ERROR_LOG - - dest_name=$(basename $src) - if cp -r $src $BACKUP_DIR/$dest_name 2>> $ERROR_LOG; then - backup_count=$((backup_count + 1)) - echo " Success: $src" >> $ERROR_LOG - else - error_count=$((error_count + 1)) - echo " Error: Failed to backup $src" >> $ERROR_LOG - fi - else - echo "Warning: $src not found, skipping" | tee -a $ERROR_LOG - error_count=$((error_count + 1)) - fi -done - -# Create manifest -cat << MANIFEST > $BACKUP_DIR/manifest.txt -Backup Manifest -=============== -Date: $DATE -Time: $(date "+%H:%M:%S") -Source Directories: $SOURCE_DIRS -Successful Backups: $backup_count -Errors: $error_count -MANIFEST - -# Generate tree of backup -tree -h $BACKUP_DIR > $BACKUP_DIR/contents.txt - -echo "Backup completed: $BACKUP_DIR" -echo "Summary: $backup_count successful, $error_count errors" - -# Exit with appropriate code -if [ $error_count -gt 0 ]; then - exit 1 -else - exit 0 -fi -EOF - -chmod +x backup_system.as -./backup_system.as -``` - -## Interactive Features - -### Command History - -- **Persistent History**: Commands saved in `~/.agfs_shell_history` -- **Navigation**: Use ↑/↓ arrow keys -- **Customizable**: Set `HISTFILE` variable to change location - -```bash -agfs:/> export HISTFILE=/tmp/my_history.txt -agfs:/> # Commands now saved to /tmp/my_history.txt -``` - -### Tab Completion - -- **Command Completion**: Tab completes command names -- **Path Completion**: Tab completes file and directory paths -- **AGFS-Aware**: Works with AGFS filesystem - -```bash -agfs:/> ec # Completes to "echo" -agfs:/> cat /lo # Completes to "/local/" -agfs:/> ls /local/tmp/te # Completes to "/local/tmp/test.txt" -``` - -### Multiline Editing - -- **Backslash Continuation**: End line with `\` -- **Quote Matching**: Unclosed quotes continue to next line -- **Bracket Matching**: Unclosed `()` or `{}` continue - -```bash -agfs:/> echo "This is a \ -> very long \ -> message" -This is a very long message - -agfs:/> if [ -f /local/tmp/file.txt ]; then -> cat /local/tmp/file.txt -> fi -``` - -### Keyboard Shortcuts - -- **Ctrl-A**: Move to beginning of line -- **Ctrl-E**: Move to end of line -- **Ctrl-K**: Delete from cursor to end -- **Ctrl-U**: Delete from cursor to beginning -- **Ctrl-W**: Delete word before cursor -- **Ctrl-L**: Clear screen -- **Ctrl-D**: Exit shell (when line empty) -- **Ctrl-C**: Cancel current input - -## Complex Examples - -### Example 1: Log Analysis Pipeline - -```bash -#!/usr/bin/env uv run agfs-shell - -# Analyze application logs across multiple servers - -LOG_DIR=/local/tmp/logs -OUTPUT_DIR=/local/tmp/analysis - -# Create directories -mkdir /local/tmp/logs -mkdir /local/tmp/analysis - -# Create sample log files for demonstration -for server in web1 web2 web3; do - echo "Creating sample log for $server..." - echo "INFO: Server $server started" > $LOG_DIR/$server.log - echo "ERROR: Connection failed" >> $LOG_DIR/$server.log - echo "CRITICAL: System failure" >> $LOG_DIR/$server.log -done - -# Find all errors -cat $LOG_DIR/*.log | grep -i error > $OUTPUT_DIR/all_errors.txt - -# Count errors by server -echo "Error Summary:" > $OUTPUT_DIR/summary.txt -for server in web1 web2 web3; do - count=$(cat $LOG_DIR/$server.log | grep -i error | wc -l) - echo "$server: $count errors" >> $OUTPUT_DIR/summary.txt -done - -# Extract unique error messages -cat $OUTPUT_DIR/all_errors.txt | \ - cut -c 21- | \ - sort | \ - uniq > $OUTPUT_DIR/unique_errors.txt - -# Find critical errors -cat $LOG_DIR/*.log | \ - grep -i critical > $OUTPUT_DIR/critical.txt - -# Generate report -cat << EOF > $OUTPUT_DIR/report.txt -Log Analysis Report -=================== -Generated: $(date) - -$(cat $OUTPUT_DIR/summary.txt) - -Unique Errors: -$(cat $OUTPUT_DIR/unique_errors.txt) - -Critical Errors: $(cat $OUTPUT_DIR/critical.txt | wc -l) -EOF - -cat $OUTPUT_DIR/report.txt -``` - -### Example 2: Data Processing Pipeline - -```bash -#!/usr/bin/env uv run agfs-shell - -# Process CSV data and generate JSON reports - -INPUT_DIR=/local/tmp/data -OUTPUT_DIR=/local/tmp/reports -TEMP_DIR=/local/tmp/temp -TIMESTAMP=$(date "+%Y%m%d_%H%M%S") - -# Create directories -mkdir $INPUT_DIR -mkdir $OUTPUT_DIR -mkdir $TEMP_DIR - -# Create sample CSV files -echo "name,value,category,score" > $INPUT_DIR/data1.csv -echo "Alice,100,A,95" >> $INPUT_DIR/data1.csv -echo "Bob,200,B,85" >> $INPUT_DIR/data1.csv -echo "Charlie,150,A,90" >> $INPUT_DIR/data1.csv - -# Process each CSV file -for csv_file in $INPUT_DIR/*.csv; do - filename=$(basename $csv_file .csv) - echo "Processing $filename..." - - # Extract specific columns (name and score - columns 1 and 4) - cat $csv_file | \ - tail -n +2 | \ - cut -f 1,4 -d ',' > $TEMP_DIR/extracted_${filename}.txt - - # Count lines - line_count=$(cat $TEMP_DIR/extracted_${filename}.txt | wc -l) - echo " Processed $line_count records from $filename" -done - -# Generate summary JSON -cat << EOF > $OUTPUT_DIR/summary_${TIMESTAMP}.json -{ - "timestamp": "$(date "+%Y-%m-%d %H:%M:%S")", - "files_processed": $(ls $INPUT_DIR/*.csv | wc -l), - "output_directory": "$OUTPUT_DIR" -} -EOF - -echo "Processing complete. Reports in $OUTPUT_DIR" -``` - -### Example 3: Backup with Verification - -```bash -#!/usr/bin/env uv run agfs-shell - -# Comprehensive backup with verification - -SOURCE=/local/tmp/important -BACKUP_NAME=backup_$(date "+%Y%m%d") -BACKUP=/local/tmp/backups/$BACKUP_NAME -MANIFEST=$BACKUP/manifest.txt - -# Create backup directories -mkdir /local/tmp/backups -mkdir $BACKUP - -# Copy files -echo "Starting backup..." > $MANIFEST -echo "Date: $(date)" >> $MANIFEST -echo "Source: $SOURCE" >> $MANIFEST -echo "" >> $MANIFEST - -file_count=0 -byte_count=0 - -for file in $SOURCE/*; do - if [ -f $file ]; then - filename=$(basename $file) - echo "Backing up: $filename" - - cp $file $BACKUP/$filename - - if [ $? -eq 0 ]; then - file_count=$((file_count + 1)) - size=$(stat $file | grep Size | cut -d: -f2) - byte_count=$((byte_count + size)) - echo " [OK] $filename" >> $MANIFEST - else - echo " [FAILED] $filename" >> $MANIFEST - fi - fi -done - -echo "" >> $MANIFEST -echo "Summary:" >> $MANIFEST -echo " Files backed up: $file_count" >> $MANIFEST -echo " Total size: $byte_count bytes" >> $MANIFEST - -# Verification -echo "" >> $MANIFEST -echo "Verification:" >> $MANIFEST - -for file in $SOURCE/*; do - if [ -f $file ]; then - filename=$(basename $file) - backup_file=$BACKUP/$filename - - if [ -f $backup_file ]; then - echo " [OK] $filename verified" >> $MANIFEST - else - echo " [MISSING] $filename" >> $MANIFEST - fi - fi -done - -cat $MANIFEST -echo "Backup completed: $BACKUP" -``` - -### Example 4: Multi-Environment Configuration Manager - -```bash -#!/usr/bin/env uv run agfs-shell - -# Manage configurations across multiple environments - -# Check arguments -if [ $# -lt 1 ]; then - echo "Usage: $0 " - echo "Environments: dev, staging, production" - exit 1 -fi - -ENV=$1 -CONFIG_DIR=/local/tmp/config -DEPLOY_DIR=/local/tmp/deployed - -# Validate environment -if [ "$ENV" != "dev" -a "$ENV" != "staging" -a "$ENV" != "production" ]; then - echo "Error: Invalid environment '$ENV'" - exit 1 -fi - -echo "Deploying configuration for: $ENV" - -# Load environment-specific config -CONFIG_FILE=$CONFIG_DIR/$ENV.env - -if [ ! -f $CONFIG_FILE ]; then - echo "Error: Configuration file not found: $CONFIG_FILE" - exit 1 -fi - -# Parse and export variables -for line in $(cat $CONFIG_FILE); do - export $line -done - -# Generate deployment manifest -MANIFEST=$DEPLOY_DIR/manifest_$ENV.txt - -cat << EOF > $MANIFEST -Deployment Manifest -=================== -Environment: $ENV -Deployed: $(date) - -Configuration: -$(cat $CONFIG_FILE) - -Mounted Filesystems: -$(plugins list | grep "->") - -Status: SUCCESS -EOF - -# Deploy to all relevant filesystems -for mount in /local/tmp /s3fs; do - if [ -d $mount ]; then - echo "Deploying to $mount..." - mkdir $mount/config - cp $CONFIG_FILE $mount/config/current.env - - if [ $? -eq 0 ]; then - echo " [OK] Deployed to $mount" - else - echo " [FAILED] Failed to deploy to $mount" - fi - fi -done - -echo "Deployment complete. Manifest: $MANIFEST" -cat $MANIFEST -``` - -## Architecture - -### Project Structure - -``` -agfs-shell/ -├── agfs_shell/ -│ ├── __init__.py # Package initialization -│ ├── streams.py # Stream classes (InputStream, OutputStream, ErrorStream) -│ ├── process.py # Process class for command execution -│ ├── pipeline.py # Pipeline class for chaining processes -│ ├── parser.py # Command line parser -│ ├── builtins.py # Built-in command implementations -│ ├── filesystem.py # AGFS filesystem abstraction -│ ├── config.py # Configuration management -│ ├── shell.py # Shell with REPL and control flow -│ ├── completer.py # Tab completion -│ ├── cli.py # CLI entry point -│ ├── exit_codes.py # Exit code constants -│ └── command_decorators.py # Command metadata -├── pyproject.toml # Project configuration -├── README.md # This file -└── examples/ - ├── example.as # Example scripts - ├── backup_system.as - └── data_pipeline.as -``` - -### Design Philosophy - -1. **Stream Abstraction**: Everything as streams (stdin/stdout/stderr) -2. **Process Composition**: Simple commands compose into complex operations -3. **Pipeline Execution**: Output of one process → input of next -4. **AGFS Integration**: All file I/O through AGFS (no local filesystem) -5. **Pure Python**: No subprocess for built-ins (educational) - -### Key Features - -- Unix-style pipelines (`|`) -- I/O Redirection (`<`, `>`, `>>`, `2>`, `2>>`) -- Heredoc (`<<` with expansion) -- Variables (`VAR=value`, `$VAR`, `${VAR}`) -- Special variables (`$?`, `$1`, `$@`, etc.) -- Arithmetic expansion (`$((expr))`) -- Command substitution (`$(cmd)`, backticks) -- Glob expansion (`*.txt`, `[abc]`) -- Control flow (`if/then/else/fi`, `for/do/done`) -- Conditional testing (`test`, `[ ]`) -- Loop control (`break`, `continue`) -- User-defined functions with local variables -- Tab completion and history -- 39+ built-in commands -- Script execution (`.as` files) -- AI integration (`llm` command) - -## Testing - -### Run Built-in Tests - -```bash -# Run Python tests -uv run pytest - -# Run specific test -uv run pytest tests/test_builtins.py -v - -# Run shell script tests -./test_simple_for.agfsh -./test_for.agfsh -./test_for_with_comment.agfsh - -# Run function tests -./test_functions_working.as # Comprehensive test of all working features -``` - -### Manual Testing - -```bash -# Start interactive shell -uv run agfs-shell - -# Test pipelines -agfs:/> echo "hello world" | grep hello | wc -w - -# Test variables -agfs:/> NAME="Alice" -agfs:/> echo "Hello, $NAME" - -# Test arithmetic -agfs:/> count=0 -agfs:/> count=$((count + 1)) -agfs:/> echo $count - -# Test control flow -agfs:/> for i in 1 2 3; do echo $i; done - -# Test file operations -agfs:/> echo "test" > /local/tmp/test.txt -agfs:/> cat /local/tmp/test.txt - -# Test functions -agfs:/> add() { echo $(($1 + $2)); } -agfs:/> add 5 3 -8 - -agfs:/> greet() { echo "Hello, $1!"; } -agfs:/> greet Alice -Hello, Alice! -``` - -## Configuration - -### Server URL - -Configure AGFS server URL: - -```bash -# Via environment variable (preferred) -export AGFS_API_URL=http://192.168.1.100:8080 -uv run agfs-shell - -# Via command line argument -uv run agfs-shell --agfs-api-url http://192.168.1.100:8080 - -# Via config file -# Create ~/.agfs_shell_config with: -# server_url: http://192.168.1.100:8080 -``` - -### Timeout - -Set request timeout: - -```bash -export AGFS_TIMEOUT=60 -uv run agfs-shell --timeout 60 -``` - -## Technical Limitations - -### Function Implementation - -The current function implementation supports: -- ✅ Function definition and direct calls -- ✅ Parameters (`$1`, `$2`, `$@`, etc.) -- ✅ Local variables with `local` command -- ✅ Return values with `return` command -- ✅ Control flow (`if`, `for`) inside functions -- ✅ Arithmetic expressions with local variables - -**Known Limitations:** -- ⚠️ **Command substitution with functions**: Limited support due to output capture architecture -- ❌ **Recursive functions**: Requires full call stack implementation (future enhancement) -- ❌ **Complex nested command substitutions**: May not capture output correctly - -**Why these limitations exist:** - -The shell's current architecture executes commands through a Process/Pipeline system where each process has its own I/O streams. Capturing function output in command substitution contexts requires either: - -1. **Call Stack Implementation** (like real programming languages): - - Each function call gets its own execution frame - - Frames contain local variables, parameters, and output buffer - - Proper stack unwinding for recursion - -2. **Unified Output Capture**: - - Refactor `execute()` to support optional output capture mode - - All Process objects write to configurable output streams - - Capture and restore output contexts across call chain - -These are planned for Phase 2 of the implementation. - -**Workarounds:** -- Use direct function calls instead of command substitution when possible -- Use iterative approaches instead of recursion -- Store results in global variables if needed - -## Contributing - -This is an experimental/educational project. Contributions welcome! - -1. Fork the repository -2. Create your feature branch -3. Add tests for new features -4. Submit a pull request - -**Areas for Contribution:** -- Implement full call stack for recursive functions -- Improve output capture mechanism -- Add more built-in commands -- Enhance error handling - -## License - -[Add your license here] - -## Credits - -Built with: -- [pyagfs](https://github.com/c4pt0r/pyagfs) - Python client for AGFS -- [Rich](https://github.com/Textualize/rich) - Terminal formatting -- Pure Python - No external dependencies for core shell - ---- - -**Note**: This is an experimental shell for educational purposes and AGFS integration. Not recommended for production use. diff --git a/third_party/agfs/agfs-shell/agfs_shell/__init__.py b/third_party/agfs/agfs-shell/agfs_shell/__init__.py deleted file mode 100644 index ba786f372..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""AGFS Shell - Experimental shell with pipeline support""" - -__version__ = "1.4.0" diff --git a/third_party/agfs/agfs-shell/agfs_shell/arg_parser.py b/third_party/agfs/agfs-shell/agfs_shell/arg_parser.py deleted file mode 100644 index 8f2622dbf..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/arg_parser.py +++ /dev/null @@ -1,314 +0,0 @@ -""" -Unified argument parsing for built-in commands - -Provides consistent argument parsing to avoid duplication in builtins.py -""" - -from typing import List, Tuple, Dict, Optional, Set -from dataclasses import dataclass - - -@dataclass -class ParsedArgs: - """ - Result of argument parsing - - Attributes: - positional: Positional arguments (non-flags) - flags: Set of boolean flags (e.g., '-l', '-r') - options: Dictionary of options with values (e.g., {'-n': '10'}) - remaining: Unparsed arguments after '--' - """ - positional: List[str] - flags: Set[str] - options: Dict[str, str] - remaining: List[str] - - def has_flag(self, *flags: str) -> bool: - """Check if any of the given flags is present""" - for flag in flags: - if flag in self.flags: - return True - return False - - def get_option(self, *names: str, default: Optional[str] = None) -> Optional[str]: - """Get value of first matching option""" - for name in names: - if name in self.options: - return self.options[name] - return default - - def get_int_option(self, *names: str, default: int = 0) -> int: - """Get integer value of option""" - value = self.get_option(*names) - if value is None: - return default - try: - return int(value) - except ValueError: - return default - - -class StandardArgParser: - """ - Standard argument parser for built-in commands - - Handles common patterns: - - Boolean flags: -l, -r, -h, etc. - - Options with values: -n 10, --count=5 - - Combined flags: -lh (same as -l -h) - - End of options: -- (everything after is positional) - """ - - def __init__(self, known_flags: Optional[Set[str]] = None, - known_options: Optional[Set[str]] = None): - """ - Initialize parser - - Args: - known_flags: Set of recognized boolean flags (e.g., {'-l', '-r'}) - known_options: Set of options that take values (e.g., {'-n', '--count'}) - """ - self.known_flags = known_flags or set() - self.known_options = known_options or set() - - def parse(self, args: List[str]) -> ParsedArgs: - """ - Parse argument list - - Args: - args: List of command arguments - - Returns: - ParsedArgs object with parsed arguments - """ - positional = [] - flags = set() - options = {} - remaining = [] - - i = 0 - end_of_options = False - - while i < len(args): - arg = args[i] - - # Check for end-of-options marker - if arg == '--': - end_of_options = True - remaining = args[i+1:] - break - - # After --, everything is positional - if end_of_options: - positional.append(arg) - i += 1 - continue - - # Check for options and flags - if arg.startswith('-') and len(arg) > 1: - # Long option with value: --name=value - if arg.startswith('--') and '=' in arg: - name, value = arg.split('=', 1) - options[name] = value - i += 1 - # Long option requiring next arg: --count 10 - elif arg.startswith('--') and arg in self.known_options: - if i + 1 < len(args): - options[arg] = args[i + 1] - i += 2 - else: - # Option without value - treat as flag - flags.add(arg) - i += 1 - # Short option requiring next arg: -n 10 - elif arg in self.known_options: - if i + 1 < len(args): - options[arg] = args[i + 1] - i += 2 - else: - # Option without value - treat as flag - flags.add(arg) - i += 1 - # Combined short flags: -lh or individual flag -l - else: - # Try to split combined flags - if not arg.startswith('--'): - for char in arg[1:]: - flags.add(f'-{char}') - else: - flags.add(arg) - i += 1 - else: - # Positional argument - positional.append(arg) - i += 1 - - return ParsedArgs( - positional=positional, - flags=flags, - options=options, - remaining=remaining - ) - - -def parse_standard_flags(args: List[str], valid_flags: str = '') -> Tuple[Set[str], List[str]]: - """ - Simple flag parser for common cases - - Args: - args: Argument list - valid_flags: String of valid flag characters (e.g., 'lhr' for -l, -h, -r) - - Returns: - Tuple of (flags_set, remaining_args) - - Example: - >>> flags, args = parse_standard_flags(['-lh', 'file.txt'], 'lhr') - >>> flags - {'-l', '-h'} - >>> args - ['file.txt'] - """ - flags = set() - remaining = [] - - for arg in args: - if arg.startswith('-') and len(arg) > 1 and arg != '--': - # Extract flags from argument like -lh - for char in arg[1:]: - if char in valid_flags: - flags.add(f'-{char}') - else: - remaining.append(arg) - - return flags, remaining - - -def has_any_flag(args: List[str], *flag_chars: str) -> bool: - """ - Quick check if any flag is present - - Args: - args: Argument list - *flag_chars: Flag characters to check (without '-') - - Returns: - True if any flag is present - - Example: - >>> has_any_flag(['-l', 'file.txt'], 'l', 'h') - True - >>> has_any_flag(['file.txt'], 'l', 'h') - False - """ - for arg in args: - if arg.startswith('-') and len(arg) > 1: - for char in flag_chars: - if char in arg[1:]: - return True - return False - - -def extract_option_value(args: List[str], *option_names: str, default: Optional[str] = None) -> Tuple[Optional[str], List[str]]: - """ - Extract option value and return remaining args - - Args: - args: Argument list - *option_names: Option names to look for (e.g., '-n', '--count') - default: Default value if option not found - - Returns: - Tuple of (option_value, remaining_args) - - Example: - >>> value, remaining = extract_option_value(['-n', '10', 'file.txt'], '-n', '--count') - >>> value - '10' - >>> remaining - ['file.txt'] - """ - remaining = [] - value = default - i = 0 - - while i < len(args): - arg = args[i] - - # Check for option=value format - if '=' in arg: - for opt in option_names: - if arg.startswith(f'{opt}='): - value = arg.split('=', 1)[1] - i += 1 - continue - - # Check for option value format - matched = False - for opt in option_names: - if arg == opt: - if i + 1 < len(args): - value = args[i + 1] - i += 2 - matched = True - break - else: - i += 1 - matched = True - break - - if not matched: - remaining.append(arg) - i += 1 - - return value, remaining - - -class CommandArgumentValidator: - """Validate command arguments based on rules""" - - @staticmethod - def require_args(args: List[str], min_count: int = 1, error_msg: str = None) -> bool: - """ - Check if minimum number of arguments is present - - Args: - args: Argument list - min_count: Minimum required arguments - error_msg: Custom error message - - Returns: - True if valid, raises ValueError otherwise - - Raises: - ValueError: If not enough arguments - """ - if len(args) < min_count: - msg = error_msg or f"missing operand (expected at least {min_count} argument(s))" - raise ValueError(msg) - return True - - @staticmethod - def require_exact_args(args: List[str], count: int, error_msg: str = None) -> bool: - """Check if exact number of arguments is present""" - if len(args) != count: - msg = error_msg or f"expected exactly {count} argument(s), got {len(args)}" - raise ValueError(msg) - return True - - @staticmethod - def validate_int(value: str, arg_name: str = "value") -> int: - """Validate and convert string to integer""" - try: - return int(value) - except ValueError: - raise ValueError(f"invalid integer value for {arg_name}: {value}") - - @staticmethod - def validate_positive_int(value: str, arg_name: str = "value") -> int: - """Validate positive integer""" - num = CommandArgumentValidator.validate_int(value, arg_name) - if num < 0: - raise ValueError(f"{arg_name} must be positive: {value}") - return num diff --git a/third_party/agfs/agfs-shell/agfs_shell/ast_nodes.py b/third_party/agfs/agfs-shell/agfs_shell/ast_nodes.py deleted file mode 100644 index db031cd37..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/ast_nodes.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -AST (Abstract Syntax Tree) nodes for shell control flow structures. - -This module defines the node types used to represent parsed shell constructs -in a structured, type-safe manner. -""" - -from dataclasses import dataclass, field -from typing import List, Optional, Tuple, Union - - -@dataclass -class Statement: - """Base class for all statement nodes""" - pass - - -@dataclass -class CommandStatement(Statement): - """ - A simple command execution. - - Examples: - echo hello - ls -la - test -f file.txt - """ - command: str # Raw command string (will be parsed by shell.execute) - - -@dataclass -class ForStatement(Statement): - """ - for var in items; do body; done - - Examples: - for i in 1 2 3; do echo $i; done - for f in *.txt; do cat $f; done - """ - variable: str # Loop variable name - items_raw: str # Raw items string (before expansion) - body: List[Statement] = field(default_factory=list) - - -@dataclass -class WhileStatement(Statement): - """ - while condition; do body; done - - Examples: - while true; do echo loop; done - while test $i -lt 10; do echo $i; i=$((i+1)); done - """ - condition: str # Condition command string - body: List[Statement] = field(default_factory=list) - - -@dataclass -class UntilStatement(Statement): - """ - until condition; do body; done - - Opposite of while - executes until condition becomes true (exit code 0) - """ - condition: str - body: List[Statement] = field(default_factory=list) - - -@dataclass -class IfBranch: - """A single if/elif branch with condition and body""" - condition: str # Condition command string - body: List[Statement] = field(default_factory=list) - - -@dataclass -class IfStatement(Statement): - """ - if condition; then body; [elif condition; then body;]* [else body;] fi - - Examples: - if test $x -eq 1; then echo one; fi - if test -f $f; then cat $f; else echo missing; fi - """ - branches: List[IfBranch] = field(default_factory=list) # if + elif branches - else_body: Optional[List[Statement]] = None # else block - - -@dataclass -class FunctionDefinition(Statement): - """ - function_name() { body; } - - Examples: - hello() { echo "Hello $1"; } - function greet { echo "Hi"; } - """ - name: str - body: List[Statement] = field(default_factory=list) - - -# Type alias for any statement -AnyStatement = Union[ - CommandStatement, - ForStatement, - WhileStatement, - UntilStatement, - IfStatement, - FunctionDefinition -] diff --git a/third_party/agfs/agfs-shell/agfs_shell/builtins.py b/third_party/agfs/agfs-shell/agfs_shell/builtins.py deleted file mode 100644 index d34d2e349..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/builtins.py +++ /dev/null @@ -1,3715 +0,0 @@ -"""Built-in shell commands""" - -import re -import os -import datetime -from typing import List -from .process import Process -from .command_decorators import command -from .exit_codes import EXIT_CODE_BREAK, EXIT_CODE_CONTINUE, EXIT_CODE_RETURN - - -def _mode_to_rwx(mode: int) -> str: - """Convert octal file mode to rwx string format""" - # Handle both full mode (e.g., 0o100644) and just permissions (e.g., 0o644 or 420 decimal) - # Extract last 9 bits for user/group/other permissions - perms = mode & 0o777 - - def _triple(val): - """Convert 3-bit value to rwx""" - r = 'r' if val & 4 else '-' - w = 'w' if val & 2 else '-' - x = 'x' if val & 1 else '-' - return r + w + x - - # Split into user, group, other (3 bits each) - user = (perms >> 6) & 7 - group = (perms >> 3) & 7 - other = perms & 7 - - return _triple(user) + _triple(group) + _triple(other) - - -@command() -def cmd_echo(process: Process) -> int: - """Echo arguments to stdout""" - if process.args: - output = ' '.join(process.args) + '\n' - process.stdout.write(output) - else: - process.stdout.write('\n') - return 0 - - -@command(needs_path_resolution=True, supports_streaming=True) -def cmd_cat(process: Process) -> int: - """ - Concatenate and print files or stdin (streaming mode) - - Usage: cat [file...] - """ - import sys - - if not process.args: - # Read from stdin in chunks - # Use read() instead of get_value() to properly support streaming pipelines - stdin_value = process.stdin.read() - - if stdin_value: - # Data from stdin (from pipeline or buffer) - process.stdout.write(stdin_value) - process.stdout.flush() - else: - # No data in stdin, read from real stdin (interactive mode) - try: - while True: - chunk = sys.stdin.buffer.read(8192) - if not chunk: - break - process.stdout.write(chunk) - process.stdout.flush() - except KeyboardInterrupt: - process.stderr.write(b"\ncat: interrupted\n") - return 130 - else: - # Read from files in streaming mode - for filename in process.args: - try: - if process.filesystem: - # Stream file in chunks - stream = process.filesystem.read_file(filename, stream=True) - try: - for chunk in stream: - if chunk: - process.stdout.write(chunk) - process.stdout.flush() - except KeyboardInterrupt: - process.stderr.write(b"\ncat: interrupted\n") - return 130 - else: - # Fallback to local filesystem - with open(filename, 'rb') as f: - while True: - chunk = f.read(8192) - if not chunk: - break - process.stdout.write(chunk) - process.stdout.flush() - except Exception as e: - # Extract meaningful error message - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cat: {filename}: No such file or directory\n") - else: - process.stderr.write(f"cat: {filename}: {error_msg}\n") - return 1 - return 0 - - -@command(supports_streaming=True) -def cmd_grep(process: Process) -> int: - """ - Search for pattern in files or stdin - - Usage: grep [OPTIONS] PATTERN [FILE...] - - Options: - -i Ignore case - -v Invert match (select non-matching lines) - -n Print line numbers - -c Count matching lines - -l Print only filenames with matches - -h Suppress filename prefix (default for single file) - -H Print filename prefix (default for multiple files) - - Examples: - echo 'hello world' | grep hello - grep 'pattern' file.txt - grep -i 'error' *.log - grep -n 'function' code.py - grep -v 'debug' app.log - grep -c 'TODO' *.py - """ - import re - - # Parse options - ignore_case = False - invert_match = False - show_line_numbers = False - count_only = False - files_only = False - show_filename = None # None = auto, True = force, False = suppress - - args = process.args[:] - options = [] - - while args and args[0].startswith('-') and args[0] != '-': - opt = args.pop(0) - if opt == '--': - break - - for char in opt[1:]: - if char == 'i': - ignore_case = True - elif char == 'v': - invert_match = True - elif char == 'n': - show_line_numbers = True - elif char == 'c': - count_only = True - elif char == 'l': - files_only = True - elif char == 'h': - show_filename = False - elif char == 'H': - show_filename = True - else: - process.stderr.write(f"grep: invalid option -- '{char}'\n") - return 2 - - # Get pattern - if not args: - process.stderr.write("grep: missing pattern\n") - process.stderr.write("Usage: grep [OPTIONS] PATTERN [FILE...]\n") - return 2 - - pattern = args.pop(0) - files = args - - # Compile regex - try: - flags = re.IGNORECASE if ignore_case else 0 - regex = re.compile(pattern, flags) - except re.error as e: - process.stderr.write(f"grep: invalid pattern: {e}\n") - return 2 - - # Determine if we should show filenames - if show_filename is None: - show_filename = len(files) > 1 - - # Process files or stdin - total_matched = False - - if not files: - # Read from stdin - total_matched = _grep_search( - process, regex, None, invert_match, show_line_numbers, - count_only, files_only, False - ) - else: - # Read from files - for filepath in files: - try: - # Read file content - content = process.filesystem.read_file(filepath) - if isinstance(content, bytes): - content = content.decode('utf-8') - - # Create a file-like object for the content - from io import StringIO - file_obj = StringIO(content) - - matched = _grep_search( - process, regex, filepath, invert_match, show_line_numbers, - count_only, files_only, show_filename, file_obj - ) - - if matched: - total_matched = True - if files_only: - # Already printed filename, move to next file - continue - - except FileNotFoundError: - process.stderr.write(f"grep: {filepath}: No such file or directory\n") - except Exception as e: - process.stderr.write(f"grep: {filepath}: {e}\n") - - return 0 if total_matched else 1 - - -def _grep_search(process, regex, filename, invert_match, show_line_numbers, - count_only, files_only, show_filename, file_obj=None): - """ - Helper function to search for pattern in a file or stdin - - Returns True if any matches found, False otherwise - """ - if file_obj is None: - # Read from stdin - lines = process.stdin.readlines() - else: - # Read from file object - lines = file_obj.readlines() - - match_count = 0 - line_number = 0 - - for line in lines: - line_number += 1 - - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace') - else: - line_str = line - - # Remove trailing newline for matching - line_clean = line_str.rstrip('\n\r') - - # Check if line matches - matches = bool(regex.search(line_clean)) - if invert_match: - matches = not matches - - if matches: - match_count += 1 - - if files_only: - # Just print filename and stop processing this file - if filename: - process.stdout.write(f"{filename}\n") - return True - - if not count_only: - # Build output line - output_parts = [] - - if show_filename and filename: - output_parts.append(filename) - - if show_line_numbers: - output_parts.append(str(line_number)) - - # Format: filename:linenum:line or just line - if output_parts: - prefix = ':'.join(output_parts) + ':' - process.stdout.write(prefix + line_clean + '\n') - else: - process.stdout.write(line_str if line_str.endswith('\n') else line_clean + '\n') - - # If count_only, print the count - if count_only: - if show_filename and filename: - process.stdout.write(f"{filename}:{match_count}\n") - else: - process.stdout.write(f"{match_count}\n") - - return match_count > 0 - - -@command() -def cmd_wc(process: Process) -> int: - """ - Count lines, words, and bytes - - Usage: wc [-l] [-w] [-c] - """ - count_lines = False - count_words = False - count_bytes = False - - # Parse flags - flags = [arg for arg in process.args if arg.startswith('-')] - if not flags: - # Default: count all - count_lines = count_words = count_bytes = True - else: - for flag in flags: - if 'l' in flag: - count_lines = True - if 'w' in flag: - count_words = True - if 'c' in flag: - count_bytes = True - - # Read all data from stdin - data = process.stdin.read() - - lines = data.count(b'\n') - words = len(data.split()) - bytes_count = len(data) - - result = [] - if count_lines: - result.append(str(lines)) - if count_words: - result.append(str(words)) - if count_bytes: - result.append(str(bytes_count)) - - output = ' '.join(result) + '\n' - process.stdout.write(output) - - return 0 - - -@command() -def cmd_head(process: Process) -> int: - """ - Output the first part of files - - Usage: head [-n count] - """ - n = 10 # default - - # Parse -n flag - args = process.args[:] - i = 0 - while i < len(args): - if args[i] == '-n' and i + 1 < len(args): - try: - n = int(args[i + 1]) - i += 2 - continue - except ValueError: - process.stderr.write(f"head: invalid number: {args[i + 1]}\n") - return 1 - i += 1 - - # Read lines from stdin - lines = process.stdin.readlines() - for line in lines[:n]: - process.stdout.write(line) - - return 0 - - -@command(needs_path_resolution=True, supports_streaming=True) -def cmd_tail(process: Process) -> int: - """ - Output the last part of files - - Usage: tail [-n count] [-f] [-F] [file...] - - Options: - -n count Output the last count lines (default: 10) - -f Follow mode: show last n lines, then continuously follow - -F Stream mode: for streamfs/streamrotatefs only - Continuously reads from the stream without loading history - Ideal for infinite streams like /streamfs/* or /streamrotate/* - """ - import time - - n = 10 # default - follow = False - stream_only = False # -F flag: skip reading history - files = [] - - # Parse flags - args = process.args[:] - i = 0 - while i < len(args): - if args[i] == '-n' and i + 1 < len(args): - try: - n = int(args[i + 1]) - i += 2 - continue - except ValueError: - process.stderr.write(f"tail: invalid number: {args[i + 1]}\n") - return 1 - elif args[i] == '-f': - follow = True - i += 1 - elif args[i] == '-F': - follow = True - stream_only = True - i += 1 - else: - # This is a file argument - files.append(args[i]) - i += 1 - - # Handle stdin or files - if not files: - # Read from stdin - lines = process.stdin.readlines() - for line in lines[-n:]: - process.stdout.write(line) - - if follow: - process.stderr.write(b"tail: warning: following stdin is not supported\n") - - return 0 - - # Read from files - if not follow: - # Normal tail mode - read last n lines from each file - for filename in files: - try: - if not process.filesystem: - process.stderr.write(b"tail: filesystem not available\n") - return 1 - - # Use streaming mode to read entire file - stream = process.filesystem.read_file(filename, stream=True) - chunks = [] - for chunk in stream: - if chunk: - chunks.append(chunk) - content = b''.join(chunks) - lines = content.decode('utf-8', errors='replace').splitlines(keepends=True) - for line in lines[-n:]: - process.stdout.write(line) - except Exception as e: - process.stderr.write(f"tail: {filename}: {str(e)}\n") - return 1 - else: - # Follow mode - continuously read new content - if len(files) > 1: - process.stderr.write(b"tail: warning: following multiple files not yet supported, using first file\n") - - filename = files[0] - - try: - if process.filesystem: - if stream_only: - # -F mode: Stream-only mode for filesystems that support streaming - # This mode uses continuous streaming read without loading history - process.stderr.write(b"==> Continuously reading from stream <==\n") - process.stdout.flush() - - # Use continuous streaming read - try: - stream = process.filesystem.read_file(filename, stream=True) - for chunk in stream: - if chunk: - process.stdout.write(chunk) - process.stdout.flush() - except KeyboardInterrupt: - process.stderr.write(b"\n") - return 0 - except Exception as e: - error_msg = str(e) - # Check if it's a streaming-related error - if "stream mode" in error_msg.lower() or "use stream" in error_msg.lower(): - process.stderr.write(f"tail: {filename}: {error_msg}\n".encode()) - process.stderr.write(b" Note: -F requires a filesystem that supports streaming\n") - else: - process.stderr.write(f"tail: {filename}: {error_msg}\n".encode()) - return 1 - else: - # -f mode: Traditional follow mode - # First, output the last n lines - stream = process.filesystem.read_file(filename, stream=True) - chunks = [] - for chunk in stream: - if chunk: - chunks.append(chunk) - content = b''.join(chunks) - lines = content.decode('utf-8', errors='replace').splitlines(keepends=True) - for line in lines[-n:]: - process.stdout.write(line) - process.stdout.flush() - - # Get current file size - file_info = process.filesystem.get_file_info(filename) - current_size = file_info.get('size', 0) - - # Now continuously poll for new content - try: - while True: - time.sleep(0.1) # Poll every 100ms - - # Check file size - try: - file_info = process.filesystem.get_file_info(filename) - new_size = file_info.get('size', 0) - except Exception: - # File might not exist yet, keep waiting - continue - - if new_size > current_size: - # Read new content from offset using streaming - stream = process.filesystem.read_file( - filename, - offset=current_size, - size=new_size - current_size, - stream=True - ) - for chunk in stream: - if chunk: - process.stdout.write(chunk) - process.stdout.flush() - current_size = new_size - except KeyboardInterrupt: - # Clean exit on Ctrl+C - process.stderr.write(b"\n") - return 0 - else: - # No filesystem - should not happen in normal usage - process.stderr.write(b"tail: filesystem not available\n") - return 1 - - except Exception as e: - process.stderr.write(f"tail: {filename}: {str(e)}\n") - return 1 - - return 0 - - -@command(needs_path_resolution=True) -def cmd_tee(process: Process) -> int: - """ - Read from stdin and write to both stdout and files (streaming mode) - - Usage: tee [-a] [file...] - - Options: - -a Append to files instead of overwriting - """ - append = False - files = [] - - # Parse arguments - for arg in process.args: - if arg == '-a': - append = True - else: - files.append(arg) - - if files and not process.filesystem: - process.stderr.write(b"tee: filesystem not available\n") - return 1 - - # Read input lines - lines = process.stdin.readlines() - - # Write to stdout (streaming: flush after each line) - for line in lines: - process.stdout.write(line) - process.stdout.flush() - - # Write to files - if files: - if append: - # Append mode: must collect all data - content = b''.join(lines) - for filename in files: - try: - process.filesystem.write_file(filename, content, append=True) - except Exception as e: - process.stderr.write(f"tee: {filename}: {str(e)}\n".encode()) - return 1 - else: - # Non-append mode: use streaming write via iterator - # Create an iterator from lines - def line_iterator(): - for line in lines: - yield line - - for filename in files: - try: - # Pass iterator to write_file for streaming - process.filesystem.write_file(filename, line_iterator(), append=False) - except Exception as e: - process.stderr.write(f"tee: {filename}: {str(e)}\n".encode()) - return 1 - - return 0 - - -@command() -def cmd_sort(process: Process) -> int: - """ - Sort lines of text - - Usage: sort [-r] - """ - reverse = '-r' in process.args - - # Read lines from stdin - lines = process.stdin.readlines() - lines.sort(reverse=reverse) - - for line in lines: - process.stdout.write(line) - - return 0 - - -@command() -def cmd_uniq(process: Process) -> int: - """ - Report or omit repeated lines - - Usage: uniq - """ - lines = process.stdin.readlines() - if not lines: - return 0 - - prev_line = lines[0] - process.stdout.write(prev_line) - - for line in lines[1:]: - if line != prev_line: - process.stdout.write(line) - prev_line = line - - return 0 - - -@command() -def cmd_tr(process: Process) -> int: - """ - Translate characters - - Usage: tr set1 set2 - """ - if len(process.args) < 2: - process.stderr.write("tr: missing operand\n") - return 1 - - set1 = process.args[0].encode('utf-8') - set2 = process.args[1].encode('utf-8') - - if len(set1) != len(set2): - process.stderr.write("tr: sets must be same length\n") - return 1 - - # Create translation table - trans = bytes.maketrans(set1, set2) - - # Read and translate - data = process.stdin.read() - translated = data.translate(trans) - process.stdout.write(translated) - - return 0 - - -def _human_readable_size(size: int) -> str: - """Convert size in bytes to human-readable format""" - units = ['B', 'K', 'M', 'G', 'T', 'P'] - unit_index = 0 - size_float = float(size) - - while size_float >= 1024.0 and unit_index < len(units) - 1: - size_float /= 1024.0 - unit_index += 1 - - if unit_index == 0: - # Bytes - no decimal - return f"{int(size_float)}{units[unit_index]}" - elif size_float >= 10: - # >= 10 - no decimal places - return f"{int(size_float)}{units[unit_index]}" - else: - # < 10 - one decimal place - return f"{size_float:.1f}{units[unit_index]}" - - -@command(needs_path_resolution=True) -def cmd_ls(process: Process) -> int: - """ - List directory contents - - Usage: ls [-l] [-h] [path...] - - Options: - -l Use long listing format - -h Print human-readable sizes (e.g., 1K, 234M, 2G) - """ - # Parse arguments - long_format = False - human_readable = False - paths = [] - - for arg in process.args: - if arg.startswith('-') and arg != '-': - # Handle combined flags like -lh - if 'l' in arg: - long_format = True - if 'h' in arg: - human_readable = True - else: - paths.append(arg) - - # Default to current working directory if no paths specified - if not paths: - cwd = getattr(process, 'cwd', '/') - paths = [cwd] - - if not process.filesystem: - process.stderr.write("ls: filesystem not available\n") - return 1 - - # Helper function to format file info - def format_file_info(file_info, display_name=None): - """Format a single file info dict for output""" - name = display_name if display_name else file_info.get('name', '') - is_dir = file_info.get('isDir', False) or file_info.get('type') == 'directory' - size = file_info.get('size', 0) - - if long_format: - # Long format output similar to ls -l - file_type = 'd' if is_dir else '-' - - # Get mode/permissions - mode_str = file_info.get('mode', '') - if mode_str and isinstance(mode_str, str) and len(mode_str) >= 9: - # Already in rwxr-xr-x format - perms = mode_str[:9] - elif mode_str and isinstance(mode_str, int): - # Convert octal mode to rwx format - perms = _mode_to_rwx(mode_str) - else: - # Default permissions - perms = 'rwxr-xr-x' if is_dir else 'rw-r--r--' - - # Get modification time - mtime = file_info.get('modTime', file_info.get('mtime', '')) - if mtime: - # Format timestamp (YYYY-MM-DD HH:MM:SS) - if 'T' in mtime: - # ISO format: 2025-11-18T22:00:25Z - mtime = mtime.replace('T', ' ').replace('Z', '').split('.')[0] - elif len(mtime) > 19: - # Truncate to 19 chars if too long - mtime = mtime[:19] - else: - mtime = '0000-00-00 00:00:00' - - # Format: permissions size date time name - # Add color for directories (blue) - if is_dir: - # Blue color for directories - colored_name = f"\033[1;34m{name}/\033[0m" - else: - colored_name = name - - # Format size based on human_readable flag - if human_readable: - size_str = f"{_human_readable_size(size):>8}" - else: - size_str = f"{size:>8}" - - return f"{file_type}{perms} {size_str} {mtime} {colored_name}\n" - else: - # Simple formatting - if is_dir: - # Blue color for directories - return f"\033[1;34m{name}/\033[0m\n" - else: - return f"{name}\n" - - exit_code = 0 - - try: - # Process each path argument - for path in paths: - try: - # First, get info about the path to determine if it's a file or directory - path_info = process.filesystem.get_file_info(path) - is_directory = path_info.get('isDir', False) or path_info.get('type') == 'directory' - - if is_directory: - # It's a directory - list its contents - files = process.filesystem.list_directory(path) - - # Show directory name if multiple paths - if len(paths) > 1: - process.stdout.write(f"{path}:\n".encode('utf-8')) - - for file_info in files: - output = format_file_info(file_info) - process.stdout.write(output.encode('utf-8')) - - # Add blank line between directories if multiple paths - if len(paths) > 1: - process.stdout.write(b"\n") - else: - # It's a file - display info about the file itself - import os - basename = os.path.basename(path) - output = format_file_info(path_info, display_name=basename) - process.stdout.write(output.encode('utf-8')) - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"ls: {path}: No such file or directory\n") - else: - process.stderr.write(f"ls: {path}: {error_msg}\n") - exit_code = 1 - - return exit_code - except Exception as e: - error_msg = str(e) - process.stderr.write(f"ls: {error_msg}\n") - return 1 - - -@command() -def cmd_pwd(process: Process) -> int: - """ - Print working directory - - Usage: pwd - """ - # Get cwd from process metadata if available - cwd = getattr(process, 'cwd', '/') - process.stdout.write(f"{cwd}\n".encode('utf-8')) - return 0 - - -@command(no_pipeline=True, changes_cwd=True, needs_path_resolution=True) -def cmd_cd(process: Process) -> int: - """ - Change directory - - Usage: cd [path] - - Note: This is a special builtin that needs to be handled by the shell - """ - if not process.args: - # cd with no args goes to root - target_path = '/' - else: - target_path = process.args[0] - - if not process.filesystem: - process.stderr.write("cd: filesystem not available\n") - return 1 - - # Store the target path in process metadata for shell to handle - # The shell will resolve the path and verify it exists - process.cd_target = target_path - - # Return special exit code to indicate cd operation - # Shell will check for this and update cwd - return 0 - - -@command(needs_path_resolution=True) -def cmd_mkdir(process: Process) -> int: - """ - Create directory - - Usage: mkdir path - """ - if not process.args: - process.stderr.write("mkdir: missing operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("mkdir: filesystem not available\n") - return 1 - - path = process.args[0] - - try: - # Use AGFS client to create directory - process.filesystem.client.mkdir(path) - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"mkdir: {path}: {error_msg}\n") - return 1 - - -@command(needs_path_resolution=True) -def cmd_touch(process: Process) -> int: - """ - Touch file (update timestamp) - - Usage: touch file... - """ - if not process.args: - process.stderr.write("touch: missing file operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("touch: filesystem not available\n") - return 1 - - for path in process.args: - try: - process.filesystem.touch_file(path) - except Exception as e: - error_msg = str(e) - process.stderr.write(f"touch: {path}: {error_msg}\n") - return 1 - - return 0 - - -@command(needs_path_resolution=True) -def cmd_rm(process: Process) -> int: - """ - Remove file or directory - - Usage: rm [-r] path... - """ - if not process.args: - process.stderr.write("rm: missing operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("rm: filesystem not available\n") - return 1 - - recursive = False - paths = [] - - for arg in process.args: - if arg == '-r' or arg == '-rf': - recursive = True - else: - paths.append(arg) - - if not paths: - process.stderr.write("rm: missing file operand\n") - return 1 - - exit_code = 0 - - for path in paths: - try: - # Use AGFS client to remove file/directory - process.filesystem.client.rm(path, recursive=recursive) - except Exception as e: - error_msg = str(e) - process.stderr.write(f"rm: {path}: {error_msg}\n") - exit_code = 1 - - return exit_code - - -@command() -def cmd_export(process: Process) -> int: - """ - Set or display environment variables - - Usage: export [VAR=value ...] - """ - if not process.args: - # Display all environment variables (like 'env') - if hasattr(process, 'env'): - for key, value in sorted(process.env.items()): - process.stdout.write(f"{key}={value}\n".encode('utf-8')) - return 0 - - # Set environment variables - for arg in process.args: - if '=' in arg: - var_name, var_value = arg.split('=', 1) - var_name = var_name.strip() - var_value = var_value.strip() - - # Validate variable name - if var_name and var_name.replace('_', '').replace('-', '').isalnum(): - if hasattr(process, 'env'): - process.env[var_name] = var_value - else: - process.stderr.write(f"export: invalid variable name: {var_name}\n") - return 1 - else: - process.stderr.write(f"export: usage: export VAR=value\n") - return 1 - - return 0 - - -@command() -def cmd_env(process: Process) -> int: - """ - Display all environment variables - - Usage: env - """ - if hasattr(process, 'env'): - for key, value in sorted(process.env.items()): - process.stdout.write(f"{key}={value}\n".encode('utf-8')) - return 0 - - -@command() -def cmd_unset(process: Process) -> int: - """ - Unset environment variables - - Usage: unset VAR [VAR ...] - """ - if not process.args: - process.stderr.write("unset: missing variable name\n") - return 1 - - if not hasattr(process, 'env'): - return 0 - - for var_name in process.args: - if var_name in process.env: - del process.env[var_name] - - return 0 - - -@command() -def cmd_test(process: Process) -> int: - """ - Evaluate conditional expressions (similar to bash test/[) - - Usage: test EXPRESSION - [ EXPRESSION ] - - File operators: - -f FILE True if file exists and is a regular file - -d FILE True if file exists and is a directory - -e FILE True if file exists - - String operators: - -z STRING True if string is empty - -n STRING True if string is not empty - STRING1 = STRING2 True if strings are equal - STRING1 != STRING2 True if strings are not equal - - Integer operators: - INT1 -eq INT2 True if integers are equal - INT1 -ne INT2 True if integers are not equal - INT1 -gt INT2 True if INT1 is greater than INT2 - INT1 -lt INT2 True if INT1 is less than INT2 - INT1 -ge INT2 True if INT1 is greater than or equal to INT2 - INT1 -le INT2 True if INT1 is less than or equal to INT2 - - Logical operators: - ! EXPR True if expr is false - EXPR -a EXPR True if both expressions are true (AND) - EXPR -o EXPR True if either expression is true (OR) - """ - # Handle [ command - last arg should be ] - if process.command == '[': - if not process.args or process.args[-1] != ']': - process.stderr.write("[: missing ']'\n") - return 2 - # Remove the closing ] - process.args = process.args[:-1] - - if not process.args: - # Empty test is false - return 1 - - # Evaluate the expression - try: - result = _evaluate_test_expression(process.args, process) - return 0 if result else 1 - except Exception as e: - process.stderr.write(f"test: {e}\n") - return 2 - - -def _evaluate_test_expression(args: List[str], process: Process) -> bool: - """Evaluate a test expression""" - if not args: - return False - - # Single argument - test if non-empty string - if len(args) == 1: - return bool(args[0]) - - # Negation operator - if args[0] == '!': - return not _evaluate_test_expression(args[1:], process) - - # File test operators - if args[0] == '-f': - if len(args) < 2: - raise ValueError("-f requires an argument") - path = args[1] - if process.filesystem: - try: - info = process.filesystem.get_file_info(path) - is_dir = info.get('isDir', False) or info.get('type') == 'directory' - return not is_dir - except: - return False - return False - - if args[0] == '-d': - if len(args) < 2: - raise ValueError("-d requires an argument") - path = args[1] - if process.filesystem: - return process.filesystem.is_directory(path) - return False - - if args[0] == '-e': - if len(args) < 2: - raise ValueError("-e requires an argument") - path = args[1] - if process.filesystem: - return process.filesystem.file_exists(path) - return False - - # String test operators - if args[0] == '-z': - if len(args) < 2: - raise ValueError("-z requires an argument") - return len(args[1]) == 0 - - if args[0] == '-n': - if len(args) < 2: - raise ValueError("-n requires an argument") - return len(args[1]) > 0 - - # Binary operators - if len(args) >= 3: - # Logical AND - if '-a' in args: - idx = args.index('-a') - left = _evaluate_test_expression(args[:idx], process) - right = _evaluate_test_expression(args[idx+1:], process) - return left and right - - # Logical OR - if '-o' in args: - idx = args.index('-o') - left = _evaluate_test_expression(args[:idx], process) - right = _evaluate_test_expression(args[idx+1:], process) - return left or right - - # String comparison - if args[1] == '=': - return args[0] == args[2] - - if args[1] == '!=': - return args[0] != args[2] - - # Integer comparison - if args[1] in ['-eq', '-ne', '-gt', '-lt', '-ge', '-le']: - try: - left = int(args[0]) - right = int(args[2]) - if args[1] == '-eq': - return left == right - elif args[1] == '-ne': - return left != right - elif args[1] == '-gt': - return left > right - elif args[1] == '-lt': - return left < right - elif args[1] == '-ge': - return left >= right - elif args[1] == '-le': - return left <= right - except ValueError: - raise ValueError(f"integer expression expected: {args[0]} or {args[2]}") - - # Default: non-empty first argument - return bool(args[0]) - - -@command(supports_streaming=True) -def cmd_jq(process: Process) -> int: - """ - Process JSON using jq-like syntax - - Usage: - jq FILTER [file...] - cat file.json | jq FILTER - - Examples: - echo '{"name":"test"}' | jq . - cat data.json | jq '.name' - jq '.items[]' data.json - """ - try: - import jq as jq_lib - import json - except ImportError: - process.stderr.write("jq: jq library not installed (run: uv pip install jq)\n") - return 1 - - # First argument is the filter - if not process.args: - process.stderr.write("jq: missing filter expression\n") - process.stderr.write("Usage: jq FILTER [file...]\n") - return 1 - - filter_expr = process.args[0] - input_files = process.args[1:] if len(process.args) > 1 else [] - - try: - # Compile the jq filter - compiled_filter = jq_lib.compile(filter_expr) - except Exception as e: - process.stderr.write(f"jq: compile error: {e}\n") - return 1 - - # Read JSON input - json_data = [] - - if input_files: - # Read from files - for filepath in input_files: - try: - # Read file content - content = process.filesystem.read_file(filepath) - if isinstance(content, bytes): - content = content.decode('utf-8') - - # Parse JSON - data = json.loads(content) - json_data.append(data) - except FileNotFoundError: - process.stderr.write(f"jq: {filepath}: No such file or directory\n") - return 1 - except json.JSONDecodeError as e: - process.stderr.write(f"jq: {filepath}: parse error: {e}\n") - return 1 - except Exception as e: - process.stderr.write(f"jq: {filepath}: {e}\n") - return 1 - else: - # Read from stdin - stdin_data = process.stdin.read() - if isinstance(stdin_data, bytes): - stdin_data = stdin_data.decode('utf-8') - - if not stdin_data.strip(): - process.stderr.write("jq: no input\n") - return 1 - - try: - data = json.loads(stdin_data) - json_data.append(data) - except json.JSONDecodeError as e: - process.stderr.write(f"jq: parse error: {e}\n") - return 1 - - # Apply filter to each JSON input - try: - for data in json_data: - # Run the filter - results = compiled_filter.input(data) - - # Output results - for result in results: - # Pretty print JSON output - output = json.dumps(result, indent=2, ensure_ascii=False) - process.stdout.write(output + '\n') - - return 0 - except Exception as e: - process.stderr.write(f"jq: filter error: {e}\n") - return 1 - - -@command(needs_path_resolution=True) -def cmd_stat(process: Process) -> int: - """ - Display file status and check if file exists - - Usage: stat path - """ - if not process.args: - process.stderr.write("stat: missing operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("stat: filesystem not available\n") - return 1 - - path = process.args[0] - - try: - # Get file info from the filesystem - file_info = process.filesystem.get_file_info(path) - - # File exists, display information - name = file_info.get('name', path.split('/')[-1] if '/' in path else path) - is_dir = file_info.get('isDir', False) or file_info.get('type') == 'directory' - size = file_info.get('size', 0) - - # Get mode/permissions - mode_str = file_info.get('mode', '') - if mode_str and isinstance(mode_str, str) and len(mode_str) >= 9: - perms = mode_str[:9] - elif mode_str and isinstance(mode_str, int): - perms = _mode_to_rwx(mode_str) - else: - perms = 'rwxr-xr-x' if is_dir else 'rw-r--r--' - - # Get modification time - mtime = file_info.get('modTime', file_info.get('mtime', '')) - if mtime: - if 'T' in mtime: - mtime = mtime.replace('T', ' ').replace('Z', '').split('.')[0] - elif len(mtime) > 19: - mtime = mtime[:19] - else: - mtime = 'unknown' - - # Build output - file_type = 'directory' if is_dir else 'regular file' - output = f" File: {name}\n" - output += f" Type: {file_type}\n" - output += f" Size: {size} bytes\n" - output += f" Mode: {perms}\n" - output += f" Modified: {mtime}\n" - - process.stdout.write(output.encode('utf-8')) - return 0 - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write("stat: No such file or directory\n") - else: - process.stderr.write(f"stat: {path}: {error_msg}\n") - return 1 - - -@command() -def cmd_upload(process: Process) -> int: - """ - Upload a local file or directory to AGFS - - Usage: upload [-r] - """ - # Parse arguments - recursive = False - args = process.args[:] - - if args and args[0] == '-r': - recursive = True - args = args[1:] - - if len(args) != 2: - process.stderr.write("upload: usage: upload [-r] \n") - return 1 - - local_path = args[0] - agfs_path = args[1] - - # Resolve agfs_path relative to current working directory - if not agfs_path.startswith('/'): - agfs_path = os.path.join(process.cwd, agfs_path) - agfs_path = os.path.normpath(agfs_path) - - try: - # Check if local path exists - if not os.path.exists(local_path): - process.stderr.write(f"upload: {local_path}: No such file or directory\n") - return 1 - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(agfs_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(local_path) - agfs_path = os.path.join(agfs_path, source_basename) - agfs_path = os.path.normpath(agfs_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if os.path.isfile(local_path): - # Upload single file - return _upload_file(process, local_path, agfs_path) - elif os.path.isdir(local_path): - if not recursive: - process.stderr.write(f"upload: {local_path}: Is a directory (use -r to upload recursively)\n") - return 1 - # Upload directory recursively - return _upload_dir(process, local_path, agfs_path) - else: - process.stderr.write(f"upload: {local_path}: Not a file or directory\n") - return 1 - - except Exception as e: - error_msg = str(e) - process.stderr.write(f"upload: {error_msg}\n") - return 1 - - -def _upload_file(process: Process, local_path: str, agfs_path: str, show_progress: bool = True) -> int: - """Helper: Upload a single file to AGFS""" - try: - with open(local_path, 'rb') as f: - data = f.read() - process.filesystem.write_file(agfs_path, data, append=False) - - if show_progress: - process.stdout.write(f"Uploaded {len(data)} bytes to {agfs_path}\n") - process.stdout.flush() - return 0 - - except Exception as e: - process.stderr.write(f"upload: {local_path}: {str(e)}\n") - return 1 - - -def _upload_dir(process: Process, local_path: str, agfs_path: str) -> int: - """Helper: Upload a directory recursively to AGFS""" - import stat as stat_module - - try: - # Create target directory in AGFS if it doesn't exist - try: - info = process.filesystem.get_file_info(agfs_path) - if not info.get('isDir', False): - process.stderr.write(f"upload: {agfs_path}: Not a directory\n") - return 1 - except Exception: - # Directory doesn't exist, create it - try: - # Use mkdir command to create directory - from pyagfs import AGFSClient - process.filesystem.client.mkdir(agfs_path) - except Exception as e: - process.stderr.write(f"upload: cannot create directory {agfs_path}: {str(e)}\n") - return 1 - - # Walk through local directory - for root, dirs, files in os.walk(local_path): - # Calculate relative path - rel_path = os.path.relpath(root, local_path) - if rel_path == '.': - current_agfs_dir = agfs_path - else: - current_agfs_dir = os.path.join(agfs_path, rel_path) - current_agfs_dir = os.path.normpath(current_agfs_dir) - - # Create subdirectories in AGFS - for dirname in dirs: - dir_agfs_path = os.path.join(current_agfs_dir, dirname) - dir_agfs_path = os.path.normpath(dir_agfs_path) - try: - process.filesystem.client.mkdir(dir_agfs_path) - except Exception: - # Directory might already exist, ignore - pass - - # Upload files - for filename in files: - local_file = os.path.join(root, filename) - agfs_file = os.path.join(current_agfs_dir, filename) - agfs_file = os.path.normpath(agfs_file) - - result = _upload_file(process, local_file, agfs_file) - if result != 0: - return result - - return 0 - - except Exception as e: - process.stderr.write(f"upload: {str(e)}\n") - return 1 - - -@command() -def cmd_download(process: Process) -> int: - """ - Download an AGFS file or directory to local filesystem - - Usage: download [-r] - """ - # Parse arguments - recursive = False - args = process.args[:] - - if args and args[0] == '-r': - recursive = True - args = args[1:] - - if len(args) != 2: - process.stderr.write("download: usage: download [-r] \n") - return 1 - - agfs_path = args[0] - local_path = args[1] - - # Resolve agfs_path relative to current working directory - if not agfs_path.startswith('/'): - agfs_path = os.path.join(process.cwd, agfs_path) - agfs_path = os.path.normpath(agfs_path) - - try: - # Check if source path is a directory - info = process.filesystem.get_file_info(agfs_path) - - # Check if destination is a local directory - if os.path.isdir(local_path): - # Destination is a directory, append source filename - source_basename = os.path.basename(agfs_path) - local_path = os.path.join(local_path, source_basename) - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"download: {agfs_path}: Is a directory (use -r to download recursively)\n") - return 1 - # Download directory recursively - return _download_dir(process, agfs_path, local_path) - else: - # Download single file - return _download_file(process, agfs_path, local_path) - - except FileNotFoundError: - process.stderr.write(f"download: {local_path}: Cannot create file\n") - return 1 - except PermissionError: - process.stderr.write(f"download: {local_path}: Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"download: {agfs_path}: No such file or directory\n") - else: - process.stderr.write(f"download: {error_msg}\n") - return 1 - - -def _download_file(process: Process, agfs_path: str, local_path: str, show_progress: bool = True) -> int: - """Helper: Download a single file from AGFS""" - try: - stream = process.filesystem.read_file(agfs_path, stream=True) - bytes_written = 0 - - with open(local_path, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - bytes_written += len(chunk) - - if show_progress: - process.stdout.write(f"Downloaded {bytes_written} bytes to {local_path}\n") - process.stdout.flush() - return 0 - - except Exception as e: - process.stderr.write(f"download: {agfs_path}: {str(e)}\n") - return 1 - - -def _download_dir(process: Process, agfs_path: str, local_path: str) -> int: - """Helper: Download a directory recursively from AGFS""" - try: - # Create local directory if it doesn't exist - os.makedirs(local_path, exist_ok=True) - - # List AGFS directory - entries = process.filesystem.list_directory(agfs_path) - - for entry in entries: - name = entry['name'] - is_dir = entry.get('isDir', False) - - agfs_item = os.path.join(agfs_path, name) - agfs_item = os.path.normpath(agfs_item) - local_item = os.path.join(local_path, name) - - if is_dir: - # Recursively download subdirectory - result = _download_dir(process, agfs_item, local_item) - if result != 0: - return result - else: - # Download file - result = _download_file(process, agfs_item, local_item) - if result != 0: - return result - - return 0 - - except Exception as e: - process.stderr.write(f"download: {str(e)}\n") - return 1 - - -@command() -def cmd_cp(process: Process) -> int: - """ - Copy files between local filesystem and AGFS - - Usage: - cp [-r] ... - cp [-r] local: # Upload from local to AGFS - cp [-r] local: # Download from AGFS to local - cp [-r] # Copy within AGFS - """ - import os - - # Parse arguments - recursive = False - args = process.args[:] - - if args and args[0] == '-r': - recursive = True - args = args[1:] - - if len(args) < 2: - process.stderr.write("cp: usage: cp [-r] ... \n") - return 1 - - # Last argument is destination, all others are sources - sources = args[:-1] - dest = args[-1] - - # Parse dest to determine if it's local - dest_is_local = dest.startswith('local:') - if dest_is_local: - dest = dest[6:] # Remove 'local:' prefix - else: - # Resolve AGFS path relative to current working directory - if not dest.startswith('/'): - dest = os.path.join(process.cwd, dest) - dest = os.path.normpath(dest) - - exit_code = 0 - - # Process each source file - for source in sources: - # Parse source to determine operation type - source_is_local = source.startswith('local:') - - if source_is_local: - source = source[6:] # Remove 'local:' prefix - else: - # Resolve AGFS path relative to current working directory - if not source.startswith('/'): - source = os.path.join(process.cwd, source) - source = os.path.normpath(source) - - # Determine operation type - if source_is_local and not dest_is_local: - # Upload: local -> AGFS - result = _cp_upload(process, source, dest, recursive) - elif not source_is_local and dest_is_local: - # Download: AGFS -> local - result = _cp_download(process, source, dest, recursive) - elif not source_is_local and not dest_is_local: - # Copy within AGFS - result = _cp_agfs(process, source, dest, recursive) - else: - # local -> local (not supported, use system cp) - process.stderr.write("cp: local to local copy not supported, use system cp command\n") - result = 1 - - if result != 0: - exit_code = result - - return exit_code - - -def _cp_upload(process: Process, local_path: str, agfs_path: str, recursive: bool = False) -> int: - """Helper: Upload local file or directory to AGFS - - Note: agfs_path should already be resolved to absolute path by caller - """ - try: - if not os.path.exists(local_path): - process.stderr.write(f"cp: {local_path}: No such file or directory\n") - return 1 - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(agfs_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(local_path) - agfs_path = os.path.join(agfs_path, source_basename) - agfs_path = os.path.normpath(agfs_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if os.path.isfile(local_path): - # Show progress - process.stdout.write(f"local:{local_path} -> {agfs_path}\n") - process.stdout.flush() - - # Upload file - with open(local_path, 'rb') as f: - process.filesystem.write_file(agfs_path, f.read(), append=False) - return 0 - - elif os.path.isdir(local_path): - if not recursive: - process.stderr.write(f"cp: {local_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Upload directory recursively - return _upload_dir(process, local_path, agfs_path) - - else: - process.stderr.write(f"cp: {local_path}: Not a file or directory\n") - return 1 - - except Exception as e: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_download(process: Process, agfs_path: str, local_path: str, recursive: bool = False) -> int: - """Helper: Download AGFS file or directory to local - - Note: agfs_path should already be resolved to absolute path by caller - """ - try: - # Check if source is a directory - info = process.filesystem.get_file_info(agfs_path) - - # Check if destination is a local directory - if os.path.isdir(local_path): - # Destination is a directory, append source filename - source_basename = os.path.basename(agfs_path) - local_path = os.path.join(local_path, source_basename) - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"cp: {agfs_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Download directory recursively - return _download_dir(process, agfs_path, local_path) - else: - # Show progress - process.stdout.write(f"{agfs_path} -> local:{local_path}\n") - process.stdout.flush() - - # Download single file - stream = process.filesystem.read_file(agfs_path, stream=True) - with open(local_path, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - return 0 - - except FileNotFoundError: - process.stderr.write(f"cp: {local_path}: Cannot create file\n") - return 1 - except PermissionError: - process.stderr.write(f"cp: {local_path}: Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cp: {agfs_path}: No such file or directory\n") - else: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_agfs(process: Process, source_path: str, dest_path: str, recursive: bool = False) -> int: - """Helper: Copy within AGFS - - Note: source_path and dest_path should already be resolved to absolute paths by caller - """ - try: - # Check if source is a directory - info = process.filesystem.get_file_info(source_path) - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(dest_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(source_path) - dest_path = os.path.join(dest_path, source_basename) - dest_path = os.path.normpath(dest_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"cp: {source_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Copy directory recursively - return _cp_agfs_dir(process, source_path, dest_path) - else: - # Show progress - process.stdout.write(f"{source_path} -> {dest_path}\n") - process.stdout.flush() - - # Copy single file - read all at once to avoid append overhead - data = process.filesystem.read_file(source_path, stream=False) - process.filesystem.write_file(dest_path, data, append=False) - - return 0 - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cp: {source_path}: No such file or directory\n") - else: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_agfs_dir(process: Process, source_path: str, dest_path: str) -> int: - """Helper: Recursively copy directory within AGFS""" - try: - # Create destination directory if it doesn't exist - try: - info = process.filesystem.get_file_info(dest_path) - if not info.get('isDir', False): - process.stderr.write(f"cp: {dest_path}: Not a directory\n") - return 1 - except Exception: - # Directory doesn't exist, create it - try: - process.filesystem.client.mkdir(dest_path) - except Exception as e: - process.stderr.write(f"cp: cannot create directory {dest_path}: {str(e)}\n") - return 1 - - # List source directory - entries = process.filesystem.list_directory(source_path) - - for entry in entries: - name = entry['name'] - is_dir = entry.get('isDir', False) - - src_item = os.path.join(source_path, name) - src_item = os.path.normpath(src_item) - dst_item = os.path.join(dest_path, name) - dst_item = os.path.normpath(dst_item) - - if is_dir: - # Recursively copy subdirectory - result = _cp_agfs_dir(process, src_item, dst_item) - if result != 0: - return result - else: - # Show progress - process.stdout.write(f"{src_item} -> {dst_item}\n") - process.stdout.flush() - - # Copy file - read all at once to avoid append overhead - data = process.filesystem.read_file(src_item, stream=False) - process.filesystem.write_file(dst_item, data, append=False) - - return 0 - - except Exception as e: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -@command() -def cmd_sleep(process: Process) -> int: - """ - Pause execution for specified seconds - - Usage: sleep SECONDS - - Examples: - sleep 1 # Sleep for 1 second - sleep 0.5 # Sleep for 0.5 seconds - sleep 5 # Sleep for 5 seconds - """ - import time - - if not process.args: - process.stderr.write("sleep: missing operand\n") - process.stderr.write("Usage: sleep SECONDS\n") - return 1 - - try: - seconds = float(process.args[0]) - if seconds < 0: - process.stderr.write("sleep: invalid time interval\n") - return 1 - - time.sleep(seconds) - return 0 - except ValueError: - process.stderr.write(f"sleep: invalid time interval '{process.args[0]}'\n") - return 1 - except KeyboardInterrupt: - process.stderr.write("\nsleep: interrupted\n") - return 130 - - -@command() -def cmd_plugins(process: Process) -> int: - """ - Manage AGFS plugins - - Usage: plugins [arguments] - - Subcommands: - list [-v] List all plugins (builtin and external) - load Load external plugin from AGFS or HTTP(S) - unload Unload external plugin - - Options: - -v Show detailed configuration parameters - - Path formats for load: - - Load from AGFS (relative to current directory) - - Load from AGFS (absolute path) - http(s):// - Load from HTTP(S) URL - - Examples: - plugins list # List all plugins - plugins list -v # List with config details - plugins load /mnt/plugins/myplugin.so # Load from AGFS (absolute) - plugins load myplugin.so # Load from current directory - plugins load ../plugins/myplugin.so # Load from relative path - plugins load https://example.com/myplugin.so # Load from HTTP(S) - plugins unload /mnt/plugins/myplugin.so # Unload plugin - """ - if not process.filesystem: - process.stderr.write("plugins: filesystem not available\n") - return 1 - - # No arguments - show usage - if len(process.args) == 0: - process.stderr.write("Usage: plugins [arguments]\n") - process.stderr.write("\nSubcommands:\n") - process.stderr.write(" list - List all plugins (builtin and external)\n") - process.stderr.write(" load - Load external plugin\n") - process.stderr.write(" unload - Unload external plugin\n") - process.stderr.write("\nPath formats for load:\n") - process.stderr.write(" - Load from AGFS (relative to current directory)\n") - process.stderr.write(" - Load from AGFS (absolute path)\n") - process.stderr.write(" http(s):// - Load from HTTP(S) URL\n") - process.stderr.write("\nExamples:\n") - process.stderr.write(" plugins list\n") - process.stderr.write(" plugins load /mnt/plugins/myplugin.so # Absolute path\n") - process.stderr.write(" plugins load myplugin.so # Current directory\n") - process.stderr.write(" plugins load ../plugins/myplugin.so # Relative path\n") - process.stderr.write(" plugins load https://example.com/myplugin.so # HTTP(S) URL\n") - return 1 - - # Handle plugin subcommands - subcommand = process.args[0].lower() - - if subcommand == "load": - if len(process.args) < 2: - process.stderr.write("Usage: plugins load \n") - process.stderr.write("\nPath formats:\n") - process.stderr.write(" - Load from AGFS (relative to current directory)\n") - process.stderr.write(" - Load from AGFS (absolute path)\n") - process.stderr.write(" http(s):// - Load from HTTP(S) URL\n") - process.stderr.write("\nExamples:\n") - process.stderr.write(" plugins load /mnt/plugins/myplugin.so # Absolute path\n") - process.stderr.write(" plugins load myplugin.so # Current directory\n") - process.stderr.write(" plugins load ../plugins/myplugin.so # Relative path\n") - process.stderr.write(" plugins load https://example.com/myplugin.so # HTTP(S) URL\n") - return 1 - - path = process.args[1] - - # Determine path type - is_http = path.startswith('http://') or path.startswith('https://') - - # Process path based on type - if is_http: - # HTTP(S) URL: use as-is, server will download it - library_path = path - else: - # AGFS path: resolve relative paths and add agfs:// prefix - # Resolve relative paths to absolute paths - if not path.startswith('/'): - # Relative path - resolve based on current working directory - cwd = getattr(process, 'cwd', '/') - path = os.path.normpath(os.path.join(cwd, path)) - library_path = f"agfs://{path}" - - try: - # Load the plugin - result = process.filesystem.client.load_plugin(library_path) - plugin_name = result.get("plugin_name", "unknown") - process.stdout.write(f"Loaded external plugin: {plugin_name}\n") - process.stdout.write(f" Source: {path}\n") - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"plugins load: {error_msg}\n") - return 1 - - elif subcommand == "unload": - if len(process.args) < 2: - process.stderr.write("Usage: plugins unload \n") - return 1 - - library_path = process.args[1] - - try: - process.filesystem.client.unload_plugin(library_path) - process.stdout.write(f"Unloaded external plugin: {library_path}\n") - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"plugins unload: {error_msg}\n") - return 1 - - elif subcommand == "list": - try: - # Check for verbose flag - verbose = '-v' in process.args[1:] or '--verbose' in process.args[1:] - - # Use new API to get detailed plugin information - plugins_info = process.filesystem.client.get_plugins_info() - - # Separate builtin and external plugins - builtin_plugins = [p for p in plugins_info if not p.get('is_external', False)] - external_plugins = [p for p in plugins_info if p.get('is_external', False)] - - # Display builtin plugins - if builtin_plugins: - process.stdout.write(f"Builtin Plugins: ({len(builtin_plugins)})\n") - for plugin in sorted(builtin_plugins, key=lambda x: x.get('name', '')): - plugin_name = plugin.get('name', 'unknown') - mounted_paths = plugin.get('mounted_paths', []) - config_params = plugin.get('config_params', []) - - if mounted_paths: - mount_list = [] - for mount in mounted_paths: - path = mount.get('path', '') - config = mount.get('config', {}) - if config: - mount_list.append(f"{path} (with config)") - else: - mount_list.append(path) - process.stdout.write(f" {plugin_name:20} -> {', '.join(mount_list)}\n") - else: - process.stdout.write(f" {plugin_name:20} (not mounted)\n") - - # Show config params if verbose and available - if verbose and config_params: - process.stdout.write(f" Config parameters:\n") - for param in config_params: - req = "*" if param.get('required', False) else " " - name = param.get('name', '') - ptype = param.get('type', '') - default = param.get('default', '') - desc = param.get('description', '') - default_str = f" (default: {default})" if default else "" - process.stdout.write(f" {req} {name:20} {ptype:10} {desc}{default_str}\n") - - process.stdout.write("\n") - - # Display external plugins - if external_plugins: - process.stdout.write(f"External Plugins: ({len(external_plugins)})\n") - for plugin in sorted(external_plugins, key=lambda x: x.get('name', '')): - plugin_name = plugin.get('name', 'unknown') - library_path = plugin.get('library_path', '') - mounted_paths = plugin.get('mounted_paths', []) - config_params = plugin.get('config_params', []) - - # Extract just the filename for display - filename = os.path.basename(library_path) if library_path else plugin_name - process.stdout.write(f" {filename}\n") - process.stdout.write(f" Plugin name: {plugin_name}\n") - - if mounted_paths: - mount_list = [] - for mount in mounted_paths: - path = mount.get('path', '') - config = mount.get('config', {}) - if config: - mount_list.append(f"{path} (with config)") - else: - mount_list.append(path) - process.stdout.write(f" Mounted at: {', '.join(mount_list)}\n") - else: - process.stdout.write(f" (Not currently mounted)\n") - - # Show config params if verbose and available - if verbose and config_params: - process.stdout.write(f" Config parameters:\n") - for param in config_params: - req = "*" if param.get('required', False) else " " - name = param.get('name', '') - ptype = param.get('type', '') - default = param.get('default', '') - desc = param.get('description', '') - default_str = f" (default: {default})" if default else "" - process.stdout.write(f" {req} {name:20} {ptype:10} {desc}{default_str}\n") - else: - process.stdout.write("No external plugins loaded\n") - - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"plugins list: {error_msg}\n") - return 1 - - else: - process.stderr.write(f"plugins: unknown subcommand: {subcommand}\n") - process.stderr.write("\nUsage:\n") - process.stderr.write(" plugins list - List all plugins\n") - process.stderr.write(" plugins load - Load external plugin\n") - process.stderr.write(" plugins unload - Unload external plugin\n") - return 1 - - -@command() -def cmd_rev(process: Process) -> int: - """ - Reverse lines character-wise - - Usage: rev - - Examples: - echo 'hello' | rev # Output: olleh - echo 'abc:def' | rev # Output: fed:cba - ls -l | rev | cut -d' ' -f1 | rev # Extract filenames from ls -l - """ - lines = process.stdin.readlines() - - for line in lines: - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace') - else: - line_str = line - - # Remove trailing newline, reverse, add newline back - line_clean = line_str.rstrip('\n\r') - reversed_line = line_clean[::-1] - process.stdout.write(reversed_line + '\n') - - return 0 - - -@command() -def cmd_cut(process: Process) -> int: - """ - Cut out selected portions of each line - - Usage: cut [OPTIONS] - - Options: - -f LIST Select only these fields (comma-separated or range) - -d DELIM Use DELIM as field delimiter (default: TAB) - -c LIST Select only these characters (comma-separated or range) - - LIST can be: - N N'th field/character, counted from 1 - N-M From N'th to M'th (inclusive) - N- From N'th to end of line - -M From first to M'th (inclusive) - - Examples: - echo 'a:b:c:d' | cut -d: -f1 # Output: a - echo 'a:b:c:d' | cut -d: -f2-3 # Output: b:c - echo 'a:b:c:d' | cut -d: -f1,3 # Output: a:c - echo 'hello world' | cut -c1-5 # Output: hello - cat /etc/passwd | cut -d: -f1,3 # Get username and UID - """ - # Parse options - fields_str = None - delimiter = '\t' - chars_str = None - - args = process.args[:] - - i = 0 - while i < len(args): - if args[i] == '-f' and i + 1 < len(args): - fields_str = args[i + 1] - i += 2 - elif args[i] == '-d' and i + 1 < len(args): - delimiter = args[i + 1] - i += 2 - elif args[i] == '-c' and i + 1 < len(args): - chars_str = args[i + 1] - i += 2 - elif args[i].startswith('-f'): - # Handle -f1 format - fields_str = args[i][2:] - i += 1 - elif args[i].startswith('-d'): - # Handle -d: format - delimiter = args[i][2:] - i += 1 - elif args[i].startswith('-c'): - # Handle -c1-5 format - chars_str = args[i][2:] - i += 1 - else: - process.stderr.write(f"cut: invalid option -- '{args[i]}'\n") - return 1 - - # Check that either -f or -c is specified (but not both) - if fields_str and chars_str: - process.stderr.write("cut: only one type of list may be specified\n") - return 1 - - if not fields_str and not chars_str: - process.stderr.write("cut: you must specify a list of bytes, characters, or fields\n") - process.stderr.write("Usage: cut -f LIST [-d DELIM] or cut -c LIST\n") - return 1 - - try: - if fields_str: - # Parse field list - field_indices = _parse_cut_list(fields_str) - return _cut_fields(process, field_indices, delimiter) - else: - # Parse character list - char_indices = _parse_cut_list(chars_str) - return _cut_chars(process, char_indices) - - except ValueError as e: - process.stderr.write(f"cut: {e}\n") - return 1 - - -def _parse_cut_list(list_str: str) -> List: - """ - Parse a cut list specification (e.g., "1,3,5-7,10-") - Returns a list of (start, end) tuples representing ranges (1-indexed) - """ - ranges = [] - - for part in list_str.split(','): - part = part.strip() - - if '-' in part and not part.startswith('-'): - # Range like "5-7" or "5-" - parts = part.split('-', 1) - start_str = parts[0].strip() - end_str = parts[1].strip() if parts[1] else None - - if not start_str: - raise ValueError(f"invalid range: {part}") - - start = int(start_str) - end = int(end_str) if end_str else None - - if start < 1: - raise ValueError(f"fields and positions are numbered from 1") - - if end is not None and end < start: - raise ValueError(f"invalid range: {part}") - - ranges.append((start, end)) - - elif part.startswith('-'): - # Range like "-5" (from 1 to 5) - end_str = part[1:].strip() - if not end_str: - raise ValueError(f"invalid range: {part}") - - end = int(end_str) - if end < 1: - raise ValueError(f"fields and positions are numbered from 1") - - ranges.append((1, end)) - - else: - # Single number like "3" - num = int(part) - if num < 1: - raise ValueError(f"fields and positions are numbered from 1") - - ranges.append((num, num)) - - return ranges - - -def _cut_fields(process: Process, field_ranges: List, delimiter: str) -> int: - """ - Cut fields from input lines based on field ranges - """ - lines = process.stdin.readlines() - - for line in lines: - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace').rstrip('\n\r') - else: - line_str = line.rstrip('\n\r') - - # Split line by delimiter - fields = line_str.split(delimiter) - - # Extract selected fields - output_fields = [] - for start, end in field_ranges: - if end is None: - # Range like "3-" (from 3 to end) - for i in range(start - 1, len(fields)): - if i < len(fields) and fields[i] not in output_fields: - output_fields.append((i, fields[i])) - else: - # Range like "3-5" or single field "3" - for i in range(start - 1, end): - if i < len(fields) and fields[i] not in [f[1] for f in output_fields if f[0] == i]: - output_fields.append((i, fields[i])) - - # Sort by original field index to maintain order - output_fields.sort(key=lambda x: x[0]) - - # Output the selected fields - if output_fields: - output = delimiter.join([f[1] for f in output_fields]) + '\n' - process.stdout.write(output) - - return 0 - - -def _cut_chars(process: Process, char_ranges: List) -> int: - """ - Cut characters from input lines based on character ranges - """ - lines = process.stdin.readlines() - - for line in lines: - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace').rstrip('\n\r') - else: - line_str = line.rstrip('\n\r') - - # Extract selected characters - output_chars = [] - for start, end in char_ranges: - if end is None: - # Range like "3-" (from 3 to end) - for i in range(start - 1, len(line_str)): - if i < len(line_str): - output_chars.append((i, line_str[i])) - else: - # Range like "3-5" or single character "3" - for i in range(start - 1, end): - if i < len(line_str): - output_chars.append((i, line_str[i])) - - # Sort by original character index to maintain order - output_chars.sort(key=lambda x: x[0]) - - # Remove duplicates while preserving order - seen = set() - unique_chars = [] - for idx, char in output_chars: - if idx not in seen: - seen.add(idx) - unique_chars.append(char) - - # Output the selected characters - if unique_chars: - output = ''.join(unique_chars) + '\n' - process.stdout.write(output) - - return 0 - - -@command(needs_path_resolution=True) -def cmd_tree(process: Process) -> int: - """ - List contents of directories in a tree-like format - - Usage: tree [OPTIONS] [path] - - Options: - -L level Descend only level directories deep - -d List directories only - -a Show all files (including hidden files starting with .) - --noreport Don't print file and directory count at the end - - Examples: - tree # Show tree of current directory - tree /path/to/dir # Show tree of specific directory - tree -L 2 # Show tree with max depth of 2 - tree -d # Show only directories - tree -a # Show all files including hidden ones - """ - # Parse arguments - max_depth = None - dirs_only = False - show_hidden = False - show_report = True - path = None - - args = process.args[:] - i = 0 - while i < len(args): - if args[i] == '-L' and i + 1 < len(args): - try: - max_depth = int(args[i + 1]) - if max_depth < 0: - process.stderr.write("tree: invalid level, must be >= 0\n") - return 1 - i += 2 - continue - except ValueError: - process.stderr.write(f"tree: invalid level '{args[i + 1]}'\n") - return 1 - elif args[i] == '-d': - dirs_only = True - i += 1 - elif args[i] == '-a': - show_hidden = True - i += 1 - elif args[i] == '--noreport': - show_report = False - i += 1 - elif args[i].startswith('-'): - # Handle combined flags - if args[i] == '-L': - process.stderr.write("tree: option requires an argument -- 'L'\n") - return 1 - # Unknown option - process.stderr.write(f"tree: invalid option -- '{args[i]}'\n") - return 1 - else: - # This is the path argument - if path is not None: - process.stderr.write("tree: too many arguments\n") - return 1 - path = args[i] - i += 1 - - # Default to current working directory - if path is None: - path = getattr(process, 'cwd', '/') - - if not process.filesystem: - process.stderr.write("tree: filesystem not available\n") - return 1 - - # Check if path exists - try: - info = process.filesystem.get_file_info(path) - is_dir = info.get('isDir', False) or info.get('type') == 'directory' - - if not is_dir: - process.stderr.write(f"tree: {path}: Not a directory\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"tree: {path}: No such file or directory\n") - else: - process.stderr.write(f"tree: {path}: {error_msg}\n") - return 1 - - # Print the root path - process.stdout.write(f"{path}\n".encode('utf-8')) - - # Track statistics - stats = {'dirs': 0, 'files': 0} - - # Build and print the tree - try: - _print_tree(process, path, "", True, max_depth, 0, dirs_only, show_hidden, stats) - except Exception as e: - process.stderr.write(f"tree: error traversing {path}: {e}\n") - return 1 - - # Print report - if show_report: - if dirs_only: - report = f"\n{stats['dirs']} directories\n" - else: - report = f"\n{stats['dirs']} directories, {stats['files']} files\n" - process.stdout.write(report.encode('utf-8')) - - return 0 - - -def _print_tree(process, path, prefix, is_last, max_depth, current_depth, dirs_only, show_hidden, stats): - """ - Recursively print directory tree - - Args: - process: Process object - path: Current directory path - prefix: Prefix string for tree drawing - is_last: Whether this is the last item in the parent directory - max_depth: Maximum depth to traverse (None for unlimited) - current_depth: Current depth level - dirs_only: Only show directories - show_hidden: Show hidden files - stats: Dictionary to track file/dir counts - """ - # Check depth limit - if max_depth is not None and current_depth >= max_depth: - return - - try: - # List directory contents - entries = process.filesystem.list_directory(path) - - # Filter entries - filtered_entries = [] - for entry in entries: - name = entry.get('name', '') - - # Skip hidden files unless show_hidden is True - if not show_hidden and name.startswith('.'): - continue - - is_dir = entry.get('isDir', False) or entry.get('type') == 'directory' - - # Skip files if dirs_only is True - if dirs_only and not is_dir: - continue - - filtered_entries.append(entry) - - # Sort entries: directories first, then by name - filtered_entries.sort(key=lambda e: (not (e.get('isDir', False) or e.get('type') == 'directory'), e.get('name', ''))) - - # Process each entry - for idx, entry in enumerate(filtered_entries): - name = entry.get('name', '') - is_dir = entry.get('isDir', False) or entry.get('type') == 'directory' - is_last_entry = (idx == len(filtered_entries) - 1) - - # Update statistics - if is_dir: - stats['dirs'] += 1 - else: - stats['files'] += 1 - - # Determine the tree characters to use - if is_last_entry: - connector = "└── " - extension = " " - else: - connector = "├── " - extension = "│ " - - # Format name with color - if is_dir: - # Blue color for directories - display_name = f"\033[1;34m{name}/\033[0m" - else: - display_name = name - - # Print the entry - line = f"{prefix}{connector}{display_name}\n" - process.stdout.write(line.encode('utf-8')) - - # Recursively process subdirectories - if is_dir: - subdir_path = os.path.join(path, name) - subdir_path = os.path.normpath(subdir_path) - new_prefix = prefix + extension - - _print_tree( - process, - subdir_path, - new_prefix, - is_last_entry, - max_depth, - current_depth + 1, - dirs_only, - show_hidden, - stats - ) - - except Exception as e: - # If we can't read a directory, print an error but continue - error_msg = str(e) - if "Permission denied" in error_msg: - error_line = f"{prefix}[error opening dir]\n" - else: - error_line = f"{prefix}[error: {error_msg}]\n" - process.stdout.write(error_line.encode('utf-8')) - - -@command(needs_path_resolution=True) -def cmd_mv(process: Process) -> int: - """ - Move (rename) files and directories - - Usage: mv [OPTIONS] SOURCE DEST - mv [OPTIONS] SOURCE... DIRECTORY - - Options: - -i Prompt before overwrite (interactive mode) - -n Do not overwrite an existing file - -f Force overwrite without prompting (default) - - Path formats: - - AGFS path (default) - local: - Local filesystem path - - Examples: - mv file.txt newname.txt # Rename within AGFS - mv file1.txt file2.txt dir/ # Move multiple files to directory - mv local:file.txt /agfs/path/ # Move from local to AGFS - mv /agfs/file.txt local:~/Downloads/ # Move from AGFS to local - mv -i file.txt existing.txt # Prompt before overwriting - mv -n file.txt existing.txt # Don't overwrite if exists - """ - # Parse options - interactive = False - no_clobber = False - force = True # Default behavior - args = process.args[:] - sources = [] - - i = 0 - while i < len(args): - if args[i] == '-i': - interactive = True - force = False - i += 1 - elif args[i] == '-n': - no_clobber = True - force = False - i += 1 - elif args[i] == '-f': - force = True - interactive = False - no_clobber = False - i += 1 - elif args[i].startswith('-'): - # Handle combined flags like -in - for char in args[i][1:]: - if char == 'i': - interactive = True - force = False - elif char == 'n': - no_clobber = True - force = False - elif char == 'f': - force = True - interactive = False - no_clobber = False - else: - process.stderr.write(f"mv: invalid option -- '{char}'\n") - return 1 - i += 1 - else: - sources.append(args[i]) - i += 1 - - # Need at least source and dest - if len(sources) < 2: - process.stderr.write("mv: missing file operand\n") - process.stderr.write("Usage: mv [OPTIONS] SOURCE DEST\n") - process.stderr.write(" mv [OPTIONS] SOURCE... DIRECTORY\n") - return 1 - - dest = sources.pop() - - # Parse source and dest to determine if local or AGFS - source_paths = [] - for src in sources: - is_local = src.startswith('local:') - path = src[6:] if is_local else src - source_paths.append({'path': path, 'is_local': is_local, 'original': src}) - - dest_is_local = dest.startswith('local:') - dest_path = dest[6:] if dest_is_local else dest - - # Resolve AGFS paths relative to cwd - if not dest_is_local and not dest_path.startswith('/'): - dest_path = os.path.join(process.cwd, dest_path) - dest_path = os.path.normpath(dest_path) - - for src_info in source_paths: - if not src_info['is_local'] and not src_info['path'].startswith('/'): - src_info['path'] = os.path.join(process.cwd, src_info['path']) - src_info['path'] = os.path.normpath(src_info['path']) - - # Check if moving multiple files - if len(source_paths) > 1: - # Multiple sources - dest must be a directory - if dest_is_local: - if not os.path.isdir(dest_path): - process.stderr.write(f"mv: target '{dest}' is not a directory\n") - return 1 - else: - try: - dest_info = process.filesystem.get_file_info(dest_path) - if not (dest_info.get('isDir', False) or dest_info.get('type') == 'directory'): - process.stderr.write(f"mv: target '{dest}' is not a directory\n") - return 1 - except: - process.stderr.write(f"mv: target '{dest}' is not a directory\n") - return 1 - - # Move each source to dest directory - for src_info in source_paths: - result = _mv_single( - process, src_info['path'], dest_path, - src_info['is_local'], dest_is_local, - interactive, no_clobber, force, - src_info['original'], dest - ) - if result != 0: - return result - else: - # Single source - src_info = source_paths[0] - return _mv_single( - process, src_info['path'], dest_path, - src_info['is_local'], dest_is_local, - interactive, no_clobber, force, - src_info['original'], dest - ) - - return 0 - - -def _mv_single(process, source_path, dest_path, source_is_local, dest_is_local, - interactive, no_clobber, force, source_display, dest_display): - """ - Move a single file or directory - - Returns 0 on success, non-zero on failure - """ - import sys - - # Determine final destination path - final_dest = dest_path - - # Check if destination exists and is a directory - dest_exists = False - dest_is_dir = False - - if dest_is_local: - dest_exists = os.path.exists(dest_path) - dest_is_dir = os.path.isdir(dest_path) - else: - try: - dest_info = process.filesystem.get_file_info(dest_path) - dest_exists = True - dest_is_dir = dest_info.get('isDir', False) or dest_info.get('type') == 'directory' - except: - dest_exists = False - dest_is_dir = False - - # If dest is a directory, append source filename - if dest_exists and dest_is_dir: - source_basename = os.path.basename(source_path) - if dest_is_local: - final_dest = os.path.join(dest_path, source_basename) - else: - final_dest = os.path.join(dest_path, source_basename) - final_dest = os.path.normpath(final_dest) - - # Check if final destination exists - final_dest_exists = False - if dest_is_local: - final_dest_exists = os.path.exists(final_dest) - else: - try: - process.filesystem.get_file_info(final_dest) - final_dest_exists = True - except: - final_dest_exists = False - - # Handle overwrite protection - if final_dest_exists: - if no_clobber: - # Don't overwrite, silently skip - return 0 - - if interactive: - # Prompt user - process.stderr.write(f"mv: overwrite '{final_dest}'? (y/n) ") - process.stderr.flush() - try: - response = sys.stdin.readline().strip().lower() - if response not in ['y', 'yes']: - return 0 - except: - return 0 - - # Perform the move operation based on source and dest types - try: - if source_is_local and dest_is_local: - # Local to local - use os.rename or shutil.move - import shutil - shutil.move(source_path, final_dest) - return 0 - - elif source_is_local and not dest_is_local: - # Local to AGFS - upload then delete local - if os.path.isdir(source_path): - # Move directory - result = _upload_dir(process, source_path, final_dest) - if result == 0: - # Delete local directory after successful upload - import shutil - shutil.rmtree(source_path) - return result - else: - # Move file - with open(source_path, 'rb') as f: - data = f.read() - process.filesystem.write_file(final_dest, data, append=False) - # Delete local file after successful upload - os.remove(source_path) - return 0 - - elif not source_is_local and dest_is_local: - # AGFS to local - download then delete AGFS - source_info = process.filesystem.get_file_info(source_path) - is_dir = source_info.get('isDir', False) or source_info.get('type') == 'directory' - - if is_dir: - # Move directory - result = _download_dir(process, source_path, final_dest) - if result == 0: - # Delete AGFS directory after successful download - process.filesystem.client.rm(source_path, recursive=True) - return result - else: - # Move file - stream = process.filesystem.read_file(source_path, stream=True) - with open(final_dest, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - # Delete AGFS file after successful download - process.filesystem.client.rm(source_path, recursive=False) - return 0 - - else: - # AGFS to AGFS - use rename if supported, otherwise copy + delete - # Check if source exists - source_info = process.filesystem.get_file_info(source_path) - - # Try to use AGFS rename/move if available - if hasattr(process.filesystem.client, 'rename'): - process.filesystem.client.rename(source_path, final_dest) - elif hasattr(process.filesystem.client, 'mv'): - process.filesystem.client.mv(source_path, final_dest) - else: - # Fallback: copy then delete - is_dir = source_info.get('isDir', False) or source_info.get('type') == 'directory' - - if is_dir: - # Copy directory recursively - result = _cp_agfs_dir(process, source_path, final_dest) - if result != 0: - return result - # Delete source directory - process.filesystem.client.rm(source_path, recursive=True) - else: - # Copy file - data = process.filesystem.read_file(source_path, stream=False) - process.filesystem.write_file(final_dest, data, append=False) - # Delete source file - process.filesystem.client.rm(source_path, recursive=False) - - return 0 - - except FileNotFoundError: - process.stderr.write(f"mv: cannot stat '{source_display}': No such file or directory\n") - return 1 - except PermissionError: - process.stderr.write(f"mv: cannot move '{source_display}': Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"mv: cannot stat '{source_display}': No such file or directory\n") - else: - process.stderr.write(f"mv: cannot move '{source_display}' to '{dest_display}': {error_msg}\n") - return 1 - - -@command() -def cmd_basename(process: Process) -> int: - """ - Extract filename from path - Usage: basename PATH [SUFFIX] - - Examples: - basename /local/path/to/file.txt # file.txt - basename /local/path/to/file.txt .txt # file - """ - if not process.args: - process.stderr.write("basename: missing operand\n") - process.stderr.write("Usage: basename PATH [SUFFIX]\n") - return 1 - - path = process.args[0] - suffix = process.args[1] if len(process.args) > 1 else None - - # Extract basename - basename = os.path.basename(path) - - # Remove suffix if provided - if suffix and basename.endswith(suffix): - basename = basename[:-len(suffix)] - - process.stdout.write(basename + '\n') - return 0 - - -@command() -def cmd_dirname(process: Process) -> int: - """ - Extract directory from path - Usage: dirname PATH - - Examples: - dirname /local/path/to/file.txt # /local/path/to - dirname /local/file.txt # /local - dirname file.txt # . - """ - if not process.args: - process.stderr.write("dirname: missing operand\n") - process.stderr.write("Usage: dirname PATH\n") - return 1 - - path = process.args[0] - - # Extract dirname - dirname = os.path.dirname(path) - - # If dirname is empty, use '.' - if not dirname: - dirname = '.' - - process.stdout.write(dirname + '\n') - return 0 - - -@command() -def cmd_help(process: Process) -> int: - """ - Display help information for built-in commands - - Usage: ? [command] - help [command] - - Without arguments: List all available commands - With command name: Show detailed help for that command - - Examples: - ? # List all commands - ? ls # Show help for ls command - help grep # Show help for grep command - """ - if not process.args: - # Show all commands - process.stdout.write("Available built-in commands:\n\n") - - # Get all commands from BUILTINS, sorted alphabetically - # Exclude '[' as it's an alias for 'test' - commands = sorted([cmd for cmd in BUILTINS.keys() if cmd != '[']) - - # Group commands by category for better organization - categories = { - 'File Operations': ['ls', 'tree', 'cat', 'mkdir', 'rm', 'mv', 'cp', 'stat', 'upload', 'download'], - 'Text Processing': ['grep', 'wc', 'head', 'tail', 'sort', 'uniq', 'tr', 'rev', 'cut', 'jq'], - 'System': ['pwd', 'cd', 'echo', 'env', 'export', 'unset', 'sleep'], - 'Testing': ['test'], - 'AGFS Management': ['mount', 'plugins'], - } - - # Display categorized commands - for category, cmd_list in categories.items(): - category_cmds = [cmd for cmd in cmd_list if cmd in commands] - if category_cmds: - process.stdout.write(f"\033[1;36m{category}:\033[0m\n") - for cmd in category_cmds: - func = BUILTINS[cmd] - # Get first line of docstring as short description - if func.__doc__: - lines = func.__doc__.strip().split('\n') - # Find first non-empty line after initial whitespace - short_desc = "" - for line in lines: - line = line.strip() - if line and not line.startswith('Usage:'): - short_desc = line - break - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m {short_desc}\n") - else: - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m\n") - process.stdout.write("\n") - - # Show uncategorized commands if any - categorized = set() - for cmd_list in categories.values(): - categorized.update(cmd_list) - uncategorized = [cmd for cmd in commands if cmd not in categorized] - if uncategorized: - process.stdout.write(f"\033[1;36mOther:\033[0m\n") - for cmd in uncategorized: - func = BUILTINS[cmd] - if func.__doc__: - lines = func.__doc__.strip().split('\n') - short_desc = "" - for line in lines: - line = line.strip() - if line and not line.startswith('Usage:'): - short_desc = line - break - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m {short_desc}\n") - else: - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m\n") - process.stdout.write("\n") - - process.stdout.write("Type '? ' for detailed help on a specific command.\n") - return 0 - - # Show help for specific command - command_name = process.args[0] - - if command_name not in BUILTINS: - process.stderr.write(f"?: unknown command '{command_name}'\n") - process.stderr.write("Type '?' to see all available commands.\n") - return 1 - - func = BUILTINS[command_name] - - if not func.__doc__: - process.stdout.write(f"No help available for '{command_name}'\n") - return 0 - - # Display the full docstring - process.stdout.write(f"\033[1;36mCommand: {command_name}\033[0m\n\n") - - # Format the docstring nicely - docstring = func.__doc__.strip() - - # Process the docstring to add colors - lines = docstring.split('\n') - for line in lines: - stripped = line.strip() - - # Highlight section headers (Usage:, Options:, Examples:, etc.) - if stripped.endswith(':') and len(stripped.split()) == 1: - process.stdout.write(f"\033[1;33m{stripped}\033[0m\n") - # Highlight option flags - elif stripped.startswith('-'): - # Split option and description - parts = stripped.split(None, 1) - if len(parts) == 2: - option, desc = parts - process.stdout.write(f" \033[1;32m{option:12}\033[0m {desc}\n") - else: - process.stdout.write(f" \033[1;32m{stripped}\033[0m\n") - # Regular line - else: - process.stdout.write(f"{line}\n") - - process.stdout.write("\n") - return 0 - - -@command() -def cmd_mount(process: Process) -> int: - """ - Mount a plugin dynamically or list mounted filesystems - - Usage: mount [ [key=value ...]] - - Without arguments: List all mounted filesystems - With arguments: Mount a new filesystem - - Examples: - mount # List all mounted filesystems - mount memfs /test/mem - mount sqlfs /test/db backend=sqlite db_path=/tmp/test.db - mount s3fs /test/s3 bucket=my-bucket region=us-west-1 access_key_id=xxx secret_access_key=yyy - """ - if not process.filesystem: - process.stderr.write("mount: filesystem not available\n") - return 1 - - # No arguments - list mounted filesystems - if len(process.args) == 0: - try: - mounts_list = process.filesystem.client.mounts() - - if not mounts_list: - process.stdout.write("No plugins mounted\n") - return 0 - - # Print mounts in Unix mount style: on (options...) - for mount in mounts_list: - path = mount.get("path", "") - plugin = mount.get("pluginName", "") - config = mount.get("config", {}) - - # Build options string from config - options = [] - for key, value in config.items(): - # Hide sensitive keys - if key in ["secret_access_key", "password", "token"]: - options.append(f"{key}=***") - else: - # Convert value to string, truncate if too long - value_str = str(value) - if len(value_str) > 50: - value_str = value_str[:47] + "..." - options.append(f"{key}={value_str}") - - # Format output line - if options: - options_str = ", ".join(options) - process.stdout.write(f"{plugin} on {path} (plugin: {plugin}, {options_str})\n") - else: - process.stdout.write(f"{plugin} on {path} (plugin: {plugin})\n") - - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"mount: {error_msg}\n") - return 1 - - # With arguments - mount a new filesystem - if len(process.args) < 2: - process.stderr.write("mount: missing operands\n") - process.stderr.write("Usage: mount [key=value ...]\n") - process.stderr.write("\nExamples:\n") - process.stderr.write(" mount memfs /test/mem\n") - process.stderr.write(" mount sqlfs /test/db backend=sqlite db_path=/tmp/test.db\n") - process.stderr.write(" mount s3fs /test/s3 bucket=my-bucket region=us-west-1\n") - return 1 - - fstype = process.args[0] - path = process.args[1] - config_args = process.args[2:] if len(process.args) > 2 else [] - - # Parse key=value config arguments - config = {} - for arg in config_args: - if '=' in arg: - key, value = arg.split('=', 1) - config[key.strip()] = value.strip() - else: - process.stderr.write(f"mount: invalid config argument: {arg}\n") - process.stderr.write("Config arguments must be in key=value format\n") - return 1 - - try: - # Use AGFS client to mount the plugin - process.filesystem.client.mount(fstype, path, config) - process.stdout.write(f"Mounted {fstype} at {path}\n") - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"mount: {error_msg}\n") - return 1 - - -@command() -def cmd_date(process: Process) -> int: - """ - Display current date and time (pure Python implementation) - - Usage: date [+FORMAT] - - Examples: - date # Wed Dec 6 10:23:45 PST 2025 - date "+%Y-%m-%d" # 2025-12-06 - date "+%Y-%m-%d %H:%M:%S" # 2025-12-06 10:23:45 - date "+%H:%M:%S" # 10:23:45 - - Format directives: - %Y - Year with century (2025) - %y - Year without century (25) - %m - Month (01-12) - %B - Full month name (December) - %b - Abbreviated month name (Dec) - %d - Day of month (01-31) - %e - Day of month, space-padded ( 1-31) - %A - Full weekday name (Wednesday) - %a - Abbreviated weekday name (Wed) - %H - Hour (00-23) - %I - Hour (01-12) - %M - Minute (00-59) - %S - Second (00-59) - %p - AM/PM - %Z - Timezone name - %z - Timezone offset (+0800) - """ - try: - now = datetime.datetime.now() - - if len(process.args) == 0: - # Default format: "Wed Dec 6 10:23:45 PST 2025" - # Note: %Z might be empty on some systems, %z gives offset - formatted = now.strftime("%a %b %e %H:%M:%S %Z %Y") - # Clean up double spaces that might occur - formatted = ' '.join(formatted.split()) - elif len(process.args) == 1: - format_str = process.args[0] - - # Remove leading '+' if present (like date +"%Y-%m-%d") - if format_str.startswith('+'): - format_str = format_str[1:] - - # Remove quotes if present - format_str = format_str.strip('"').strip("'") - - # Apply the format - formatted = now.strftime(format_str) - else: - process.stderr.write(b"date: too many arguments\n") - process.stderr.write(b"Usage: date [+FORMAT]\n") - return 1 - - # Write output - process.stdout.write(formatted.encode('utf-8')) - process.stdout.write(b'\n') - - return 0 - - except Exception as e: - process.stderr.write(f"date: error: {str(e)}\n".encode('utf-8')) - return 1 - - -@command() -def cmd_exit(process: Process) -> int: - """ - Exit the script with an optional exit code - - Usage: exit [n] - - Exit with status n (defaults to 0). - In a script, exits the entire script. - In interactive mode, exits the shell. - - Examples: - exit # Exit with status 0 - exit 1 # Exit with status 1 - exit $? # Exit with last command's exit code - """ - import sys - - exit_code = 0 - if process.args: - try: - exit_code = int(process.args[0]) - except ValueError: - process.stderr.write(f"exit: {process.args[0]}: numeric argument required\n") - exit_code = 2 - - # Exit by raising SystemExit - sys.exit(exit_code) - - -@command() -def cmd_break(process: Process) -> int: - """ - Break out of a for loop - - Usage: break - - Exit from the innermost for loop. Can only be used inside a for loop. - - Examples: - for i in 1 2 3 4 5; do - if test $i -eq 3; then - break - fi - echo $i - done - # Output: 1, 2 (stops at 3) - """ - # Return special exit code to signal break - # This will be caught by execute_for_loop - return EXIT_CODE_BREAK - - -@command() -def cmd_continue(process: Process) -> int: - """ - Continue to next iteration of a for loop - - Usage: continue - - Skip the rest of the current loop iteration and continue with the next one. - Can only be used inside a for loop. - - Examples: - for i in 1 2 3 4 5; do - if test $i -eq 3; then - continue - fi - echo $i - done - # Output: 1, 2, 4, 5 (skips 3) - """ - # Return special exit code to signal continue - # This will be caught by execute_for_loop - return EXIT_CODE_CONTINUE - - -@command() -def cmd_llm(process: Process) -> int: - """ - Interact with LLM models using the llm library - - Usage: llm [OPTIONS] [PROMPT] - echo "text" | llm [OPTIONS] - cat files | llm [OPTIONS] [PROMPT] - cat image.jpg | llm [OPTIONS] [PROMPT] - - Options: - -m MODEL Specify the model to use (default: gpt-4o-mini) - -s SYSTEM System prompt - -k KEY API key (overrides config/env) - -c CONFIG Path to config file (default: /etc/llm.yaml) - - Configuration: - The command reads configuration from: - 1. Environment variables (e.g., OPENAI_API_KEY, ANTHROPIC_API_KEY) - 2. Config file on AGFS (default: /etc/llm.yaml) - 3. Command-line arguments (-k option) - - Config file format (YAML): - model: gpt-4o-mini - api_key: sk-... - system: You are a helpful assistant - - Image Support: - Automatically detects image input (JPEG, PNG, GIF, WebP, BMP) from stdin - and uses vision-capable models for image analysis. - - Examples: - # Text prompts - llm "What is 2+2?" - echo "Hello world" | llm - cat *.txt | llm "summarize these files" - echo "Python code" | llm "translate to JavaScript" - - # Image analysis - cat photo.jpg | llm "What's in this image?" - cat screenshot.png | llm "Describe this screenshot in detail" - cat diagram.png | llm - - # Advanced usage - llm -m claude-3-5-sonnet-20241022 "Explain quantum computing" - llm -s "You are a helpful assistant" "How do I install Python?" - """ - import sys - - try: - import llm - except ImportError: - process.stderr.write(b"llm: llm library not installed. Run: pip install llm\n") - return 1 - - # Parse arguments - model_name = None - system_prompt = None - api_key = None - config_path = "/etc/llm.yaml" - prompt_parts = [] - - i = 0 - while i < len(process.args): - arg = process.args[i] - if arg == '-m' and i + 1 < len(process.args): - model_name = process.args[i + 1] - i += 2 - elif arg == '-s' and i + 1 < len(process.args): - system_prompt = process.args[i + 1] - i += 2 - elif arg == '-k' and i + 1 < len(process.args): - api_key = process.args[i + 1] - i += 2 - elif arg == '-c' and i + 1 < len(process.args): - config_path = process.args[i + 1] - i += 2 - else: - prompt_parts.append(arg) - i += 1 - - # Load configuration from file if it exists - config = {} - try: - if process.filesystem: - config_content = process.filesystem.read_file(config_path) - if config_content: - try: - import yaml - config = yaml.safe_load(config_content.decode('utf-8')) - if not isinstance(config, dict): - config = {} - except ImportError: - # If PyYAML not available, try simple key=value parsing - config_text = config_content.decode('utf-8') - config = {} - for line in config_text.strip().split('\n'): - line = line.strip() - if line and not line.startswith('#') and ':' in line: - key, value = line.split(':', 1) - config[key.strip()] = value.strip() - except Exception: - pass # Ignore config parse errors - except Exception: - pass # Config file doesn't exist or can't be read - - # Set defaults from config or hardcoded - if not model_name: - model_name = config.get('model', 'gpt-4o-mini') - if not system_prompt: - system_prompt = config.get('system') - if not api_key: - api_key = config.get('api_key') - - # Helper function to detect if binary data is an image - def is_image(data): - """Detect if binary data is an image by checking magic numbers""" - if not data or len(data) < 8: - return False - # Check common image formats - if data.startswith(b'\xFF\xD8\xFF'): # JPEG - return True - if data.startswith(b'\x89PNG\r\n\x1a\n'): # PNG - return True - if data.startswith(b'GIF87a') or data.startswith(b'GIF89a'): # GIF - return True - if data.startswith(b'RIFF') and data[8:12] == b'WEBP': # WebP - return True - if data.startswith(b'BM'): # BMP - return True - return False - - # Get stdin content if available (keep as binary first) - stdin_binary = None - stdin_text = None - # Use read() instead of get_value() to properly support streaming pipelines - stdin_binary = process.stdin.read() - if not stdin_binary: - # Try to read from real stdin (but don't block if not available) - try: - import select - if select.select([sys.stdin], [], [], 0.0)[0]: - stdin_binary = sys.stdin.buffer.read() - except Exception: - pass # No stdin available - - # Check if stdin is an image - is_stdin_image = False - if stdin_binary: - is_stdin_image = is_image(stdin_binary) - if not is_stdin_image: - # Try to decode as text - try: - stdin_text = stdin_binary.decode('utf-8').strip() - except UnicodeDecodeError: - # Binary data but not an image we recognize - process.stderr.write(b"llm: stdin contains binary data that is not a recognized image format\n") - return 1 - - # Get prompt from args - prompt_text = None - if prompt_parts: - prompt_text = ' '.join(prompt_parts) - - # Determine the final prompt and attachments - attachments = [] - if is_stdin_image: - # Image input: use as attachment - attachments.append(llm.Attachment(content=stdin_binary)) - if prompt_text: - full_prompt = prompt_text - else: - full_prompt = "Describe this image" - elif stdin_text and prompt_text: - # Both text stdin and prompt: stdin is context, prompt is the question/instruction - full_prompt = f"{stdin_text}\n\n===\n\n{prompt_text}" - elif stdin_text: - # Only text stdin: use it as the prompt - full_prompt = stdin_text - elif prompt_text: - # Only prompt: use it as-is - full_prompt = prompt_text - else: - # Neither: error - process.stderr.write(b"llm: no prompt provided\n") - return 1 - - # Get the model - try: - model = llm.get_model(model_name) - except Exception as e: - error_msg = f"llm: failed to get model '{model_name}': {str(e)}\n" - process.stderr.write(error_msg.encode('utf-8')) - return 1 - - # Prepare prompt kwargs - prompt_kwargs = {} - if system_prompt: - prompt_kwargs['system'] = system_prompt - if api_key: - prompt_kwargs['key'] = api_key - if attachments: - prompt_kwargs['attachments'] = attachments - - # Execute the prompt - try: - response = model.prompt(full_prompt, **prompt_kwargs) - output = response.text() - process.stdout.write(output.encode('utf-8')) - if not output.endswith('\n'): - process.stdout.write(b'\n') - return 0 - except Exception as e: - error_msg = f"llm: error: {str(e)}\n" - process.stderr.write(error_msg.encode('utf-8')) - return 1 - - -@command() -def cmd_true(process: Process) -> int: - """ - Return success (exit code 0) - - Usage: true - - Always returns 0 (success). Useful in scripts and conditionals. - """ - return 0 - - -@command() -def cmd_false(process: Process) -> int: - """ - Return failure (exit code 1) - - Usage: false - - Always returns 1 (failure). Useful in scripts and conditionals. - """ - return 1 - - -@command() -def cmd_local(process: Process) -> int: - """ - Declare local variables (only valid within functions) - - Usage: local VAR=value [VAR2=value2 ...] - - Examples: - local name="Alice" - local count=0 - local path=/tmp/data - """ - # Check if we have any local scopes (we're inside a function) - # Note: This check needs to be done via env since we don't have direct access to shell - # We'll use a special marker in env to track function depth - if not process.env.get('_function_depth'): - process.stderr.write("local: can only be used in a function\n") - return 1 - - if not process.args: - process.stderr.write("local: usage: local VAR=value [VAR2=value2 ...]\n") - return 2 - - # Process each variable assignment - for arg in process.args: - if '=' not in arg: - process.stderr.write(f"local: {arg}: not a valid identifier\n") - return 1 - - parts = arg.split('=', 1) - var_name = parts[0].strip() - var_value = parts[1] if len(parts) > 1 else '' - - # Validate variable name - if not var_name or not var_name.replace('_', '').isalnum(): - process.stderr.write(f"local: {var_name}: not a valid identifier\n") - return 1 - - # Remove outer quotes if present - if len(var_value) >= 2: - if (var_value[0] == '"' and var_value[-1] == '"') or \ - (var_value[0] == "'" and var_value[-1] == "'"): - var_value = var_value[1:-1] - - # Mark this variable as local by using a special prefix in env - # This is a workaround since we don't have direct access to shell.local_scopes - process.env[f'_local_{var_name}'] = var_value - - return 0 - - -@command() -def cmd_return(process: Process) -> int: - """ - Return from a function with an optional exit status - - Usage: return [n] - - Examples: - return # Return with status 0 - return 1 # Return with status 1 - return $? # Return with last command's status - """ - # Parse exit code - exit_code = 0 - if process.args: - try: - exit_code = int(process.args[0]) - except ValueError: - process.stderr.write(f"return: {process.args[0]}: numeric argument required\n") - return 2 - - # Store return value in env for shell to retrieve - process.env['_return_value'] = str(exit_code) - - # Return special code to signal return statement - return EXIT_CODE_RETURN - - -# Registry of built-in commands (NOT YET MIGRATED) -# These commands are still in this file and haven't been moved to the commands/ directory -_OLD_BUILTINS = { - # Commands still in builtins.py: - 'cat': cmd_cat, - 'grep': cmd_grep, - 'head': cmd_head, - 'tail': cmd_tail, - 'tee': cmd_tee, - 'sort': cmd_sort, - 'uniq': cmd_uniq, - 'tr': cmd_tr, - 'cut': cmd_cut, - 'ls': cmd_ls, - 'tree': cmd_tree, - 'cd': cmd_cd, - 'mkdir': cmd_mkdir, - 'touch': cmd_touch, - 'rm': cmd_rm, - 'mv': cmd_mv, - 'export': cmd_export, - 'env': cmd_env, - 'unset': cmd_unset, - 'test': cmd_test, - '[': cmd_test, # [ is an alias for test - 'stat': cmd_stat, - 'jq': cmd_jq, - 'llm': cmd_llm, - 'upload': cmd_upload, - 'download': cmd_download, - 'cp': cmd_cp, - 'sleep': cmd_sleep, - 'plugins': cmd_plugins, - 'mount': cmd_mount, - 'date': cmd_date, - 'exit': cmd_exit, - 'break': cmd_break, - 'continue': cmd_continue, - 'local': cmd_local, - 'return': cmd_return, - '?': cmd_help, - 'help': cmd_help, -} - -# Load all commands from the new commands/ directory -# These commands have been migrated to individual files -from .commands import load_all_commands, BUILTINS as NEW_COMMANDS - -# Load all command modules to populate the new registry -load_all_commands() - -# Combine old and new commands for backward compatibility -# New commands take precedence if there's a duplicate -BUILTINS = {**_OLD_BUILTINS, **NEW_COMMANDS} - - -def get_builtin(command: str): - """Get a built-in command executor""" - return BUILTINS.get(command) diff --git a/third_party/agfs/agfs-shell/agfs_shell/cli.py b/third_party/agfs/agfs-shell/agfs_shell/cli.py deleted file mode 100644 index a139a76e4..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/cli.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Main CLI entry point for agfs-shell""" - -import sys -import os -import argparse -from .shell import Shell -from .config import Config -from .exit_codes import ( - EXIT_CODE_CONTINUE, - EXIT_CODE_BREAK, - EXIT_CODE_FOR_LOOP_NEEDED, - EXIT_CODE_WHILE_LOOP_NEEDED, - EXIT_CODE_IF_STATEMENT_NEEDED, - EXIT_CODE_HEREDOC_NEEDED, - EXIT_CODE_FUNCTION_DEF_NEEDED -) - - -def execute_script_file(shell, script_path, script_args=None): - """Execute a script file line by line - - Args: - shell: Shell instance - script_path: Path to script file - script_args: List of arguments to pass to script (accessible as $1, $2, etc.) - """ - # Set script name and arguments as environment variables - shell.env['0'] = script_path # Script name - - if script_args: - for i, arg in enumerate(script_args, start=1): - shell.env[str(i)] = arg - shell.env['#'] = str(len(script_args)) - shell.env['@'] = ' '.join(script_args) - else: - shell.env['#'] = '0' - shell.env['@'] = '' - - try: - with open(script_path, 'r') as f: - lines = f.readlines() - - exit_code = 0 - i = 0 - while i < len(lines): - line = lines[i].strip() - line_num = i + 1 - - # Skip empty lines and comments - if not line or line.startswith('#'): - i += 1 - continue - - # Execute the command - try: - exit_code = shell.execute(line) - - # Check if for-loop needs to be collected - if exit_code == EXIT_CODE_FOR_LOOP_NEEDED: - # Collect for/do/done loop - for_lines = [line] - for_depth = 1 # Track nesting depth - i += 1 - while i < len(lines): - next_line = lines[i].strip() - for_lines.append(next_line) - # Strip comments before checking keywords - next_line_no_comment = shell._strip_comment(next_line).strip() - # Count nested for loops - if next_line_no_comment.startswith('for '): - for_depth += 1 - elif next_line_no_comment == 'done': - for_depth -= 1 - if for_depth == 0: - break - i += 1 - - # Execute the for loop - exit_code = shell.execute_for_loop(for_lines) - # Reset control flow codes to 0 for script execution - if exit_code in [EXIT_CODE_CONTINUE, EXIT_CODE_BREAK]: - exit_code = 0 - # Check if while-loop needs to be collected - elif exit_code == EXIT_CODE_WHILE_LOOP_NEEDED: - # Collect while/do/done loop - while_lines = [line] - while_depth = 1 # Track nesting depth - i += 1 - while i < len(lines): - next_line = lines[i].strip() - while_lines.append(next_line) - # Strip comments before checking keywords - next_line_no_comment = shell._strip_comment(next_line).strip() - # Count nested while loops - if next_line_no_comment.startswith('while '): - while_depth += 1 - elif next_line_no_comment == 'done': - while_depth -= 1 - if while_depth == 0: - break - i += 1 - - # Execute the while loop - exit_code = shell.execute_while_loop(while_lines) - # Reset control flow codes to 0 for script execution - if exit_code in [EXIT_CODE_CONTINUE, EXIT_CODE_BREAK]: - exit_code = 0 - # Check if function definition needs to be collected - elif exit_code == EXIT_CODE_FUNCTION_DEF_NEEDED: - # Collect function definition - func_lines = [line] - brace_depth = 1 # We've seen the opening { - i += 1 - while i < len(lines): - next_line = lines[i].strip() - func_lines.append(next_line) - # Track braces - brace_depth += next_line.count('{') - brace_depth -= next_line.count('}') - if brace_depth == 0: - break - i += 1 - - # Parse and store the function using AST parser - func_ast = shell.control_parser.parse_function_definition(func_lines) - if func_ast and func_ast.name: - shell.functions[func_ast.name] = { - 'name': func_ast.name, - 'body': func_ast.body, - 'is_ast': True - } - exit_code = 0 - else: - sys.stderr.write(f"Error at line {line_num}: invalid function definition\n") - return 1 - - # Check if if-statement needs to be collected - elif exit_code == EXIT_CODE_IF_STATEMENT_NEEDED: - # Collect if/then/else/fi statement with depth tracking - if_lines = [line] - if_depth = 1 # Track nesting depth - i += 1 - while i < len(lines): - next_line = lines[i].strip() - if_lines.append(next_line) - # Strip comments before checking keywords - next_line_no_comment = shell._strip_comment(next_line).strip() - # Track nested if statements - if next_line_no_comment.startswith('if '): - if_depth += 1 - elif next_line_no_comment == 'fi': - if_depth -= 1 - if if_depth == 0: - break - i += 1 - - # Execute the if statement - exit_code = shell.execute_if_statement(if_lines) - # Note: Non-zero exit code from if/for/while is normal - # (condition evaluated to false or loop completed) - # Update $? with the exit code but don't stop on non-zero - # (bash default behavior - scripts continue unless set -e) - shell.env['?'] = str(exit_code) - except SystemExit as e: - # Handle exit command - return the exit code - return e.code if e.code is not None else 0 - except Exception as e: - sys.stderr.write(f"Error at line {line_num}: {str(e)}\n") - return 1 - - i += 1 - - return exit_code - except KeyboardInterrupt: - # Ctrl-C during script execution - exit with code 130 (128 + SIGINT) - sys.stderr.write("\n") - return 130 - except SystemExit as e: - # Handle exit command at top level - return e.code if e.code is not None else 0 - except FileNotFoundError: - sys.stderr.write(f"agfs-shell: {script_path}: No such file or directory\n") - return 127 - except Exception as e: - sys.stderr.write(f"agfs-shell: {script_path}: {str(e)}\n") - return 1 - - -def main(): - """Main entry point for the shell""" - # Parse command line arguments - parser = argparse.ArgumentParser( - description='agfs-shell - Experimental shell with AGFS integration', - add_help=False # We'll handle help ourselves - ) - parser.add_argument('--agfs-api-url', - dest='agfs_api_url', - help='AGFS API URL (default: http://localhost:8080 or $AGFS_API_URL)', - default=None) - parser.add_argument('--timeout', - dest='timeout', - type=int, - help='Request timeout in seconds (default: 30 or $AGFS_TIMEOUT)', - default=None) - parser.add_argument('-c', - dest='command_string', - help='Execute command string', - default=None) - parser.add_argument('--help', '-h', action='store_true', - help='Show this help message') - parser.add_argument('--webapp', - action='store_true', - help='Start web application server') - parser.add_argument('--webapp-host', - dest='webapp_host', - default='localhost', - help='Web app host (default: localhost)') - parser.add_argument('--webapp-port', - dest='webapp_port', - type=int, - default=3000, - help='Web app port (default: 3000)') - parser.add_argument('script', nargs='?', help='Script file to execute') - parser.add_argument('args', nargs='*', help='Arguments to script (or command if no script)') - - # Use parse_known_args to allow command-specific flags to pass through - args, unknown = parser.parse_known_args() - - # Merge unknown args with args - they should all be part of the command - if unknown: - # Insert unknown args at the beginning since they came before positional args - args.args = unknown + args.args - - # Show help if requested - if args.help: - parser.print_help() - sys.exit(0) - - # Create configuration - config = Config.from_args(server_url=args.agfs_api_url, timeout=args.timeout) - - # Initialize shell with configuration - shell = Shell(server_url=config.server_url, timeout=config.timeout) - - # Check if webapp mode is requested - if args.webapp: - # Start web application server - try: - from .webapp_server import run_server - run_server(shell, host=args.webapp_host, port=args.webapp_port) - except ImportError as e: - sys.stderr.write(f"Error: Web app dependencies not installed.\n") - sys.stderr.write(f"Install with: uv sync --extra webapp\n") - sys.exit(1) - except Exception as e: - sys.stderr.write(f"Error starting web app: {e}\n") - sys.exit(1) - return - - # Determine mode of execution - # Priority: -c flag > script file > command args > interactive - - if args.command_string: - # Mode 1: -c "command string" - command = args.command_string - stdin_data = None - import re - import select - has_input_redir = bool(re.search(r'\s<\s', command)) - if not sys.stdin.isatty() and not has_input_redir: - if select.select([sys.stdin], [], [], 0.0)[0]: - stdin_data = sys.stdin.buffer.read() - - # Check if command contains semicolons (multiple commands) - # Split intelligently: respect if/then/else/fi, for/do/done blocks, and functions - if ';' in command: - # Smart split that tracks brace depth for functions - import re - commands = [] - current_cmd = [] - in_control_flow = False - control_flow_type = None - brace_depth = 0 - - for part in command.split(';'): - part = part.strip() - if not part: - continue - - # Track brace depth for functions - brace_depth += part.count('{') - part.count('}') - - # Check if this part starts a control flow statement or function - if not in_control_flow: - if part.startswith('if '): - in_control_flow = True - control_flow_type = 'if' - current_cmd.append(part) - elif part.startswith('for '): - in_control_flow = True - control_flow_type = 'for' - current_cmd.append(part) - elif part.startswith('while '): - in_control_flow = True - control_flow_type = 'while' - current_cmd.append(part) - elif re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)', part) or part.startswith('function '): - # Function definition - current_cmd.append(part) - if brace_depth == 0 and '}' in part: - # Complete single-line function (e.g., "foo() { echo hi; }") - commands.append('; '.join(current_cmd)) - current_cmd = [] - else: - in_control_flow = True - control_flow_type = 'function' - else: - # Regular command - commands.append(part) - else: - # We're in a control flow statement - current_cmd.append(part) - # Check if this part ends the control flow statement - ended = False - if control_flow_type == 'if' and part.strip() == 'fi': - ended = True - elif control_flow_type == 'for' and part.strip() == 'done': - ended = True - elif control_flow_type == 'while' and part.strip() == 'done': - ended = True - elif control_flow_type == 'function' and brace_depth == 0: - ended = True - - if ended: - commands.append('; '.join(current_cmd)) - current_cmd = [] - in_control_flow = False - control_flow_type = None - - # Add any remaining command - if current_cmd: - commands.append('; '.join(current_cmd)) - - # Execute each command in sequence - exit_code = 0 - for cmd in commands: - exit_code = shell.execute(cmd, stdin_data=stdin_data) - stdin_data = None # Only first command gets stdin - if exit_code != 0 and exit_code not in [ - EXIT_CODE_FOR_LOOP_NEEDED, - EXIT_CODE_WHILE_LOOP_NEEDED, - EXIT_CODE_IF_STATEMENT_NEEDED, - EXIT_CODE_HEREDOC_NEEDED, - EXIT_CODE_FUNCTION_DEF_NEEDED - ]: - # Stop on error (unless it's a special code) - break - sys.exit(exit_code) - else: - # Single command - exit_code = shell.execute(command, stdin_data=stdin_data) - sys.exit(exit_code) - - elif args.script and os.path.isfile(args.script): - # Mode 2: script file - exit_code = execute_script_file(shell, args.script, script_args=args.args) - sys.exit(exit_code) - - elif args.script: - # Mode 3: command with arguments - command_parts = [args.script] + args.args - command = ' '.join(command_parts) - stdin_data = None - import re - import select - has_input_redir = bool(re.search(r'\s<\s', command)) - if not sys.stdin.isatty() and not has_input_redir: - if select.select([sys.stdin], [], [], 0.0)[0]: - stdin_data = sys.stdin.buffer.read() - exit_code = shell.execute(command, stdin_data=stdin_data) - sys.exit(exit_code) - - else: - # Mode 4: Interactive REPL - shell.repl() - - -if __name__ == '__main__': - main() diff --git a/third_party/agfs/agfs-shell/agfs_shell/command_decorators.py b/third_party/agfs/agfs-shell/agfs_shell/command_decorators.py deleted file mode 100644 index 2bfdc0da7..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/command_decorators.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Command metadata and decorator system for agfs-shell""" - -from functools import wraps -from typing import Optional, Set, Callable - - -class CommandMetadata: - """Store and manage command metadata""" - - _registry = {} - - @classmethod - def register(cls, func: Callable, **metadata) -> Callable: - """ - Register a command with its metadata - - Args: - func: The command function - **metadata: Command metadata (needs_path_resolution, supports_streaming, etc.) - - Returns: - The original function (for decorator chaining) - """ - # Extract command name from function name (cmd_cat -> cat) - cmd_name = func.__name__.replace('cmd_', '') - cls._registry[cmd_name] = metadata - return func - - @classmethod - def get_metadata(cls, command_name: str) -> dict: - """ - Get metadata for a command - - Args: - command_name: Name of the command - - Returns: - Dictionary of metadata, or empty dict if command not found - """ - return cls._registry.get(command_name, {}) - - @classmethod - def needs_path_resolution(cls, command_name: str) -> bool: - """Check if command needs path resolution for its arguments""" - return cls.get_metadata(command_name).get('needs_path_resolution', False) - - @classmethod - def supports_streaming(cls, command_name: str) -> bool: - """Check if command supports streaming I/O""" - return cls.get_metadata(command_name).get('supports_streaming', False) - - @classmethod - def no_pipeline(cls, command_name: str) -> bool: - """Check if command cannot be used in pipelines""" - return cls.get_metadata(command_name).get('no_pipeline', False) - - @classmethod - def changes_cwd(cls, command_name: str) -> bool: - """Check if command changes the current working directory""" - return cls.get_metadata(command_name).get('changes_cwd', False) - - @classmethod - def get_path_arg_indices(cls, command_name: str) -> Optional[Set[int]]: - """ - Get indices of arguments that should be treated as paths - - Returns: - Set of argument indices, or None if all non-flag args are paths - """ - return cls.get_metadata(command_name).get('path_arg_indices', None) - - @classmethod - def all_commands(cls) -> list: - """Get list of all registered command names""" - return list(cls._registry.keys()) - - @classmethod - def get_commands_with_feature(cls, feature: str) -> list: - """ - Get list of commands that have a specific feature enabled - - Args: - feature: Feature name (e.g., 'needs_path_resolution', 'supports_streaming') - - Returns: - List of command names with that feature - """ - return [ - cmd_name for cmd_name, metadata in cls._registry.items() - if metadata.get(feature, False) - ] - - -def command( - name: Optional[str] = None, - needs_path_resolution: bool = False, - supports_streaming: bool = False, - no_pipeline: bool = False, - changes_cwd: bool = False, - path_arg_indices: Optional[Set[int]] = None -): - """ - Decorator to register a command with metadata - - Args: - name: Command name (defaults to function name without 'cmd_' prefix) - needs_path_resolution: Whether command arguments need path resolution - supports_streaming: Whether command supports streaming I/O - no_pipeline: Whether command cannot be used in pipelines - changes_cwd: Whether command changes current working directory - path_arg_indices: Set of argument indices that are paths (None = all non-flag args) - - Example: - @command(needs_path_resolution=True, supports_streaming=True) - def cmd_cat(process): - '''Read and concatenate files''' - # implementation... - """ - def decorator(func: Callable) -> Callable: - cmd_name = name or func.__name__.replace('cmd_', '') - - metadata = { - 'needs_path_resolution': needs_path_resolution, - 'supports_streaming': supports_streaming, - 'no_pipeline': no_pipeline, - 'changes_cwd': changes_cwd, - 'path_arg_indices': path_arg_indices, - } - - CommandMetadata.register(func, **metadata) - - @wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return wrapper - - return decorator diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/__init__.py b/third_party/agfs/agfs-shell/agfs_shell/commands/__init__.py deleted file mode 100644 index 39647a5e4..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/__init__.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Command registry for agfs-shell builtin commands. - -This module provides the command registration and discovery mechanism. -Each command is implemented in a separate module file under this directory. -""" - -from typing import Dict, Callable, Optional -from ..process import Process - -# Global command registry -_COMMANDS: Dict[str, Callable[[Process], int]] = {} - - -def register_command(*names: str): - """ - Decorator to register a command function. - - Args: - *names: One or more command names (for aliases like 'test' and '[') - - Example: - @register_command('echo') - def cmd_echo(process: Process) -> int: - ... - - @register_command('test', '[') - def cmd_test(process: Process) -> int: - ... - """ - def decorator(func: Callable[[Process], int]): - for name in names: - _COMMANDS[name] = func - return func - return decorator - - -def get_builtin(command: str) -> Optional[Callable[[Process], int]]: - """ - Get a built-in command executor by name. - - Args: - command: The command name to look up - - Returns: - The command function, or None if not found - """ - return _COMMANDS.get(command) - - -def load_all_commands(): - """ - Import all command modules to populate the registry. - - This function imports all command modules from this package, - which causes their @register_command decorators to execute - and populate the _COMMANDS registry. - """ - # Import all command modules here - # Each import will execute the @register_command decorator - # and add the command to the registry - - # This will be populated as we migrate commands - # For now, we'll import them dynamically - import importlib - import pkgutil - import os - - # Get the directory containing this __init__.py - package_dir = os.path.dirname(__file__) - - # Iterate through all .py files in the commands directory - for _, module_name, _ in pkgutil.iter_modules([package_dir]): - if module_name != 'base': # Skip base.py as it's not a command - try: - importlib.import_module(f'.{module_name}', package=__name__) - except Exception as e: - # Log but don't fail if a command module has issues - import sys - print(f"Warning: Failed to load command module {module_name}: {e}", file=sys.stderr) - - -# Backward compatibility: BUILTINS dictionary -# This allows old code to use BUILTINS dict while we migrate -BUILTINS = _COMMANDS - - -__all__ = ['register_command', 'get_builtin', 'load_all_commands', 'BUILTINS'] diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/base.py b/third_party/agfs/agfs-shell/agfs_shell/commands/base.py deleted file mode 100644 index 304b2e0f6..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/base.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -Base utilities for command implementations. - -This module provides common helper functions that command modules can use -to reduce code duplication and maintain consistency. -""" - -from typing import List, Optional -from ..process import Process - - -def write_error(process: Process, message: str, prefix_command: bool = True): - """ - Write an error message to stderr. - - Args: - process: The process object - message: The error message - prefix_command: If True, prefix message with command name - """ - if prefix_command: - process.stderr.write(f"{process.command}: {message}\n") - else: - process.stderr.write(f"{message}\n") - - -def validate_arg_count(process: Process, min_args: int = 0, max_args: Optional[int] = None, - usage: str = "") -> bool: - """ - Validate the number of arguments. - - Args: - process: The process object - min_args: Minimum required arguments - max_args: Maximum allowed arguments (None = unlimited) - usage: Usage string to display on error - - Returns: - True if valid, False if invalid (error already written to stderr) - """ - arg_count = len(process.args) - - if arg_count < min_args: - write_error(process, f"missing operand") - if usage: - process.stderr.write(f"usage: {usage}\n") - return False - - if max_args is not None and arg_count > max_args: - write_error(process, f"too many arguments") - if usage: - process.stderr.write(f"usage: {usage}\n") - return False - - return True - - -def parse_flags_and_args(args: List[str], known_flags: Optional[set] = None) -> tuple: - """ - Parse command arguments into flags and positional arguments. - - Args: - args: List of arguments - known_flags: Set of known flag names (e.g., {'-r', '-h', '-a'}) - If None, all args starting with '-' are treated as flags - - Returns: - Tuple of (flags_dict, positional_args) - flags_dict maps flag name to True (e.g., {'-r': True}) - positional_args is list of non-flag arguments - """ - flags = {} - positional = [] - i = 0 - - while i < len(args): - arg = args[i] - - # Check for '--' which stops flag parsing - if arg == '--': - # All remaining args are positional - positional.extend(args[i + 1:]) - break - - # Check if it looks like a flag - if arg.startswith('-') and len(arg) > 1: - if known_flags is None or arg in known_flags: - flags[arg] = True - i += 1 - else: - # Unknown flag, treat as positional - positional.append(arg) - i += 1 - else: - # Positional argument - positional.append(arg) - i += 1 - - return flags, positional - - -def has_flag(flags: dict, *flag_names: str) -> bool: - """ - Check if any of the given flags are present. - - Args: - flags: Dictionary of flags (from parse_flags_and_args) - *flag_names: One or more flag names to check - - Returns: - True if any of the flags are present - - Example: - >>> flags = {'-r': True, '-v': True} - >>> has_flag(flags, '-r') - True - >>> has_flag(flags, '-a') - False - >>> has_flag(flags, '-r', '--recursive') - True - """ - return any(name in flags for name in flag_names) - - -__all__ = [ - 'write_error', - 'validate_arg_count', - 'parse_flags_and_args', - 'has_flag', -] diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/basename.py b/third_party/agfs/agfs-shell/agfs_shell/commands/basename.py deleted file mode 100644 index 494b4c63f..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/basename.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -BASENAME command - extract filename from path. -""" - -import os -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('basename') -def cmd_basename(process: Process) -> int: - """ - Extract filename from path - Usage: basename PATH [SUFFIX] - - Examples: - basename /local/path/to/file.txt # file.txt - basename /local/path/to/file.txt .txt # file - """ - if not process.args: - process.stderr.write("basename: missing operand\n") - process.stderr.write("Usage: basename PATH [SUFFIX]\n") - return 1 - - path = process.args[0] - suffix = process.args[1] if len(process.args) > 1 else None - - # Extract basename - basename = os.path.basename(path) - - # Remove suffix if provided - if suffix and basename.endswith(suffix): - basename = basename[:-len(suffix)] - - process.stdout.write(basename + '\n') - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/break_cmd.py b/third_party/agfs/agfs-shell/agfs_shell/commands/break_cmd.py deleted file mode 100644 index b42637f66..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/break_cmd.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -BREAK command - break out of a loop. - -Note: Module name is break_cmd.py because 'break' is a Python keyword. -""" - -from ..process import Process -from ..command_decorators import command -from ..control_flow import BreakException -from . import register_command - - -@command() -@register_command('break') -def cmd_break(process: Process) -> int: - """ - Break out of a loop - - Usage: break [n] - - Exit from the innermost enclosing loop, or from n enclosing loops. - - Arguments: - n - Number of loops to break out of (default: 1) - - Examples: - # Break from innermost loop - for i in 1 2 3 4 5; do - if test $i -eq 3; then - break - fi - echo $i - done - # Output: 1, 2 (stops at 3) - - # Break from two nested loops - for i in 1 2; do - for j in a b c; do - echo $i$j - break 2 - done - done - # Output: 1a (breaks out of both loops) - """ - levels = 1 - if process.args: - try: - levels = int(process.args[0]) - if levels < 1: - levels = 1 - except ValueError: - process.stderr.write(b"break: numeric argument required\n") - return 1 - - # Raise exception to be caught by executor - raise BreakException(levels=levels) diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/cat.py b/third_party/agfs/agfs-shell/agfs_shell/commands/cat.py deleted file mode 100644 index 6f2df42b1..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/cat.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -CAT command - concatenate and print files. -""" - -import sys -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True, supports_streaming=True) -@register_command('cat') -def cmd_cat(process: Process) -> int: - """ - Concatenate and print files or stdin (streaming mode) - - Usage: cat [file...] - """ - if not process.args: - # Read from stdin in chunks - # Use read() instead of get_value() to properly support streaming pipelines - stdin_value = process.stdin.read() - - if stdin_value: - # Data from stdin (from pipeline or buffer) - process.stdout.write(stdin_value) - process.stdout.flush() - else: - # No data in stdin, read from real stdin (interactive mode) - try: - while True: - chunk = sys.stdin.buffer.read(8192) - if not chunk: - break - process.stdout.write(chunk) - process.stdout.flush() - except KeyboardInterrupt: - # Re-raise to allow proper signal propagation in script mode - raise - else: - # Read from files in streaming mode - for filename in process.args: - try: - if process.filesystem: - # Stream file in chunks - stream = process.filesystem.read_file(filename, stream=True) - try: - for chunk in stream: - if chunk: - process.stdout.write(chunk) - process.stdout.flush() - except KeyboardInterrupt: - # Re-raise to allow proper signal propagation in script mode - raise - else: - # Fallback to local filesystem - with open(filename, 'rb') as f: - while True: - chunk = f.read(8192) - if not chunk: - break - process.stdout.write(chunk) - process.stdout.flush() - except Exception as e: - # Extract meaningful error message - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cat: {filename}: No such file or directory\n") - else: - process.stderr.write(f"cat: {filename}: {error_msg}\n") - return 1 - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/cd.py b/third_party/agfs/agfs-shell/agfs_shell/commands/cd.py deleted file mode 100644 index 8f4a36ff8..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/cd.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -CD command - change directory. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(no_pipeline=True, changes_cwd=True, needs_path_resolution=True) -@register_command('cd') -def cmd_cd(process: Process) -> int: - """ - Change directory - - Usage: cd [path] - - Note: This is a special builtin that needs to be handled by the shell - """ - if not process.args: - # cd with no args goes to root - target_path = '/' - else: - target_path = process.args[0] - - if not process.filesystem: - process.stderr.write("cd: filesystem not available\n") - return 1 - - # Store the target path in process metadata for shell to handle - # The shell will resolve the path and verify it exists - process.cd_target = target_path - - # Return special exit code to indicate cd operation - # Shell will check for this and update cwd - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/continue_cmd.py b/third_party/agfs/agfs-shell/agfs_shell/commands/continue_cmd.py deleted file mode 100644 index 1bf3d2c03..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/continue_cmd.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -CONTINUE command - continue to next iteration of a loop. - -Note: Module name is continue_cmd.py because 'continue' is a Python keyword. -""" - -from ..process import Process -from ..command_decorators import command -from ..control_flow import ContinueException -from . import register_command - - -@command() -@register_command('continue') -def cmd_continue(process: Process) -> int: - """ - Continue to next iteration of a loop - - Usage: continue [n] - - Skip the rest of the current loop iteration and continue with the next one. - If n is specified, continue the nth enclosing loop. - - Arguments: - n - Which enclosing loop to continue (default: 1) - - Examples: - # Continue innermost loop - for i in 1 2 3 4 5; do - if test $i -eq 3; then - continue - fi - echo $i - done - # Output: 1, 2, 4, 5 (skips 3) - - # Continue outer loop (skip inner loop entirely) - for i in 1 2; do - for j in a b c; do - if test "$j" = "b"; then - continue 2 - fi - echo $i$j - done - done - # Output: 1a, 2a (continues outer loop when j=b) - """ - levels = 1 - if process.args: - try: - levels = int(process.args[0]) - if levels < 1: - levels = 1 - except ValueError: - process.stderr.write(b"continue: numeric argument required\n") - return 1 - - # Raise exception to be caught by executor - raise ContinueException(levels=levels) diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/cp.py b/third_party/agfs/agfs-shell/agfs_shell/commands/cp.py deleted file mode 100644 index 3697a6ee2..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/cp.py +++ /dev/null @@ -1,572 +0,0 @@ -""" -CP command - copy files between local filesystem and AGFS. -""" - -import os -from ..process import Process -from ..command_decorators import command -from . import register_command - - -def _upload_dir(process: Process, local_path: str, agfs_path: str) -> int: - """Helper: Upload a directory recursively to AGFS""" - import stat as stat_module - - try: - # Create target directory in AGFS if it doesn't exist - try: - info = process.filesystem.get_file_info(agfs_path) - if not info.get('isDir', False): - process.stderr.write(f"upload: {agfs_path}: Not a directory\n") - return 1 - except Exception: - # Directory doesn't exist, create it - try: - # Use mkdir command to create directory - from pyagfs import AGFSClient - process.filesystem.client.mkdir(agfs_path) - except Exception as e: - process.stderr.write(f"upload: cannot create directory {agfs_path}: {str(e)}\n") - return 1 - - # Walk through local directory - for root, dirs, files in os.walk(local_path): - # Calculate relative path - rel_path = os.path.relpath(root, local_path) - if rel_path == '.': - current_agfs_dir = agfs_path - else: - current_agfs_dir = os.path.join(agfs_path, rel_path) - current_agfs_dir = os.path.normpath(current_agfs_dir) - - # Create subdirectories in AGFS - for dirname in dirs: - dir_agfs_path = os.path.join(current_agfs_dir, dirname) - dir_agfs_path = os.path.normpath(dir_agfs_path) - try: - process.filesystem.client.mkdir(dir_agfs_path) - except Exception: - # Directory might already exist, ignore - pass - - # Upload files - for filename in files: - local_file = os.path.join(root, filename) - agfs_file = os.path.join(current_agfs_dir, filename) - agfs_file = os.path.normpath(agfs_file) - - result = _upload_file(process, local_file, agfs_file) - if result != 0: - return result - - return 0 - - except Exception as e: - process.stderr.write(f"upload: {str(e)}\n") - return 1 - - - - -def _download_dir(process: Process, agfs_path: str, local_path: str) -> int: - """Helper: Download a directory recursively from AGFS""" - try: - # Create local directory if it doesn't exist - os.makedirs(local_path, exist_ok=True) - - # List AGFS directory - entries = process.filesystem.list_directory(agfs_path) - - for entry in entries: - name = entry['name'] - is_dir = entry.get('isDir', False) - - agfs_item = os.path.join(agfs_path, name) - agfs_item = os.path.normpath(agfs_item) - local_item = os.path.join(local_path, name) - - if is_dir: - # Recursively download subdirectory - result = _download_dir(process, agfs_item, local_item) - if result != 0: - return result - else: - # Download file - result = _download_file(process, agfs_item, local_item) - if result != 0: - return result - - return 0 - - except Exception as e: - process.stderr.write(f"download: {str(e)}\n") - return 1 - - - - -def _cp_upload(process: Process, local_path: str, agfs_path: str, recursive: bool = False) -> int: - """Helper: Upload local file or directory to AGFS - - Note: agfs_path should already be resolved to absolute path by caller - """ - try: - if not os.path.exists(local_path): - process.stderr.write(f"cp: {local_path}: No such file or directory\n") - return 1 - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(agfs_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(local_path) - agfs_path = os.path.join(agfs_path, source_basename) - agfs_path = os.path.normpath(agfs_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if os.path.isfile(local_path): - # Show progress - process.stdout.write(f"local:{local_path} -> {agfs_path}\n") - process.stdout.flush() - - # Upload file - with open(local_path, 'rb') as f: - process.filesystem.write_file(agfs_path, f.read(), append=False) - return 0 - - elif os.path.isdir(local_path): - if not recursive: - process.stderr.write(f"cp: {local_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Upload directory recursively - return _upload_dir(process, local_path, agfs_path) - - else: - process.stderr.write(f"cp: {local_path}: Not a file or directory\n") - return 1 - - except Exception as e: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_download(process: Process, agfs_path: str, local_path: str, recursive: bool = False) -> int: - """Helper: Download AGFS file or directory to local - - Note: agfs_path should already be resolved to absolute path by caller - """ - try: - # Check if source is a directory - info = process.filesystem.get_file_info(agfs_path) - - # Check if destination is a local directory - if os.path.isdir(local_path): - # Destination is a directory, append source filename - source_basename = os.path.basename(agfs_path) - local_path = os.path.join(local_path, source_basename) - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"cp: {agfs_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Download directory recursively - return _download_dir(process, agfs_path, local_path) - else: - # Show progress - process.stdout.write(f"{agfs_path} -> local:{local_path}\n") - process.stdout.flush() - - # Download single file - stream = process.filesystem.read_file(agfs_path, stream=True) - with open(local_path, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - return 0 - - except FileNotFoundError: - process.stderr.write(f"cp: {local_path}: Cannot create file\n") - return 1 - except PermissionError: - process.stderr.write(f"cp: {local_path}: Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cp: {agfs_path}: No such file or directory\n") - else: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_agfs(process: Process, source_path: str, dest_path: str, recursive: bool = False) -> int: - """Helper: Copy within AGFS - - Note: source_path and dest_path should already be resolved to absolute paths by caller - """ - try: - # Check if source is a directory - info = process.filesystem.get_file_info(source_path) - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(dest_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(source_path) - dest_path = os.path.join(dest_path, source_basename) - dest_path = os.path.normpath(dest_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"cp: {source_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Copy directory recursively - return _cp_agfs_dir(process, source_path, dest_path) - else: - # Show progress - process.stdout.write(f"{source_path} -> {dest_path}\n") - process.stdout.flush() - - # Copy single file - read all at once to avoid append overhead - data = process.filesystem.read_file(source_path, stream=False) - process.filesystem.write_file(dest_path, data, append=False) - - return 0 - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cp: {source_path}: No such file or directory\n") - else: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_agfs_dir(process: Process, source_path: str, dest_path: str) -> int: - """Helper: Recursively copy directory within AGFS""" - try: - # Create destination directory if it doesn't exist - try: - info = process.filesystem.get_file_info(dest_path) - if not info.get('isDir', False): - process.stderr.write(f"cp: {dest_path}: Not a directory\n") - return 1 - except Exception: - # Directory doesn't exist, create it - try: - process.filesystem.client.mkdir(dest_path) - except Exception as e: - process.stderr.write(f"cp: cannot create directory {dest_path}: {str(e)}\n") - return 1 - - # List source directory - entries = process.filesystem.list_directory(source_path) - - for entry in entries: - name = entry['name'] - is_dir = entry.get('isDir', False) - - src_item = os.path.join(source_path, name) - src_item = os.path.normpath(src_item) - dst_item = os.path.join(dest_path, name) - dst_item = os.path.normpath(dst_item) - - if is_dir: - # Recursively copy subdirectory - result = _cp_agfs_dir(process, src_item, dst_item) - if result != 0: - return result - else: - # Show progress - process.stdout.write(f"{src_item} -> {dst_item}\n") - process.stdout.flush() - - # Copy file - read all at once to avoid append overhead - data = process.filesystem.read_file(src_item, stream=False) - process.filesystem.write_file(dst_item, data, append=False) - - return 0 - - except Exception as e: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - - -@command(needs_path_resolution=True) -@register_command('cp') -def cmd_cp(process: Process) -> int: - """ - Copy files between local filesystem and AGFS - - Usage: - cp [-r] ... - cp [-r] local: # Upload from local to AGFS - cp [-r] local: # Download from AGFS to local - cp [-r] # Copy within AGFS - """ - import os - - # Parse arguments - recursive = False - args = process.args[:] - - if args and args[0] == '-r': - recursive = True - args = args[1:] - - if len(args) < 2: - process.stderr.write("cp: usage: cp [-r] ... \n") - return 1 - - # Last argument is destination, all others are sources - sources = args[:-1] - dest = args[-1] - - # Parse dest to determine if it's local - dest_is_local = dest.startswith('local:') - if dest_is_local: - dest = dest[6:] # Remove 'local:' prefix - else: - # Resolve AGFS path relative to current working directory - if not dest.startswith('/'): - dest = os.path.join(process.cwd, dest) - dest = os.path.normpath(dest) - - exit_code = 0 - - # Process each source file - for source in sources: - # Parse source to determine operation type - source_is_local = source.startswith('local:') - - if source_is_local: - source = source[6:] # Remove 'local:' prefix - else: - # Resolve AGFS path relative to current working directory - if not source.startswith('/'): - source = os.path.join(process.cwd, source) - source = os.path.normpath(source) - - # Determine operation type - if source_is_local and not dest_is_local: - # Upload: local -> AGFS - result = _cp_upload(process, source, dest, recursive) - elif not source_is_local and dest_is_local: - # Download: AGFS -> local - result = _cp_download(process, source, dest, recursive) - elif not source_is_local and not dest_is_local: - # Copy within AGFS - result = _cp_agfs(process, source, dest, recursive) - else: - # local -> local (not supported, use system cp) - process.stderr.write("cp: local to local copy not supported, use system cp command\n") - result = 1 - - if result != 0: - exit_code = result - - return exit_code - - -def _cp_upload(process: Process, local_path: str, agfs_path: str, recursive: bool = False) -> int: - """Helper: Upload local file or directory to AGFS - - Note: agfs_path should already be resolved to absolute path by caller - """ - try: - if not os.path.exists(local_path): - process.stderr.write(f"cp: {local_path}: No such file or directory\n") - return 1 - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(agfs_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(local_path) - agfs_path = os.path.join(agfs_path, source_basename) - agfs_path = os.path.normpath(agfs_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if os.path.isfile(local_path): - # Show progress - process.stdout.write(f"local:{local_path} -> {agfs_path}\n") - process.stdout.flush() - - # Upload file - with open(local_path, 'rb') as f: - process.filesystem.write_file(agfs_path, f.read(), append=False) - return 0 - - elif os.path.isdir(local_path): - if not recursive: - process.stderr.write(f"cp: {local_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Upload directory recursively - return _upload_dir(process, local_path, agfs_path) - - else: - process.stderr.write(f"cp: {local_path}: Not a file or directory\n") - return 1 - - except Exception as e: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_download(process: Process, agfs_path: str, local_path: str, recursive: bool = False) -> int: - """Helper: Download AGFS file or directory to local - - Note: agfs_path should already be resolved to absolute path by caller - """ - try: - # Check if source is a directory - info = process.filesystem.get_file_info(agfs_path) - - # Check if destination is a local directory - if os.path.isdir(local_path): - # Destination is a directory, append source filename - source_basename = os.path.basename(agfs_path) - local_path = os.path.join(local_path, source_basename) - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"cp: {agfs_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Download directory recursively - return _download_dir(process, agfs_path, local_path) - else: - # Show progress - process.stdout.write(f"{agfs_path} -> local:{local_path}\n") - process.stdout.flush() - - # Download single file - stream = process.filesystem.read_file(agfs_path, stream=True) - with open(local_path, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - return 0 - - except FileNotFoundError: - process.stderr.write(f"cp: {local_path}: Cannot create file\n") - return 1 - except PermissionError: - process.stderr.write(f"cp: {local_path}: Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cp: {agfs_path}: No such file or directory\n") - else: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_agfs(process: Process, source_path: str, dest_path: str, recursive: bool = False) -> int: - """Helper: Copy within AGFS - - Note: source_path and dest_path should already be resolved to absolute paths by caller - """ - try: - # Check if source is a directory - info = process.filesystem.get_file_info(source_path) - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(dest_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(source_path) - dest_path = os.path.join(dest_path, source_basename) - dest_path = os.path.normpath(dest_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"cp: {source_path}: Is a directory (use -r to copy recursively)\n") - return 1 - # Copy directory recursively - return _cp_agfs_dir(process, source_path, dest_path) - else: - # Show progress - process.stdout.write(f"{source_path} -> {dest_path}\n") - process.stdout.flush() - - # Copy single file - read all at once to avoid append overhead - data = process.filesystem.read_file(source_path, stream=False) - process.filesystem.write_file(dest_path, data, append=False) - - return 0 - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"cp: {source_path}: No such file or directory\n") - else: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - -def _cp_agfs_dir(process: Process, source_path: str, dest_path: str) -> int: - """Helper: Recursively copy directory within AGFS""" - try: - # Create destination directory if it doesn't exist - try: - info = process.filesystem.get_file_info(dest_path) - if not info.get('isDir', False): - process.stderr.write(f"cp: {dest_path}: Not a directory\n") - return 1 - except Exception: - # Directory doesn't exist, create it - try: - process.filesystem.client.mkdir(dest_path) - except Exception as e: - process.stderr.write(f"cp: cannot create directory {dest_path}: {str(e)}\n") - return 1 - - # List source directory - entries = process.filesystem.list_directory(source_path) - - for entry in entries: - name = entry['name'] - is_dir = entry.get('isDir', False) - - src_item = os.path.join(source_path, name) - src_item = os.path.normpath(src_item) - dst_item = os.path.join(dest_path, name) - dst_item = os.path.normpath(dst_item) - - if is_dir: - # Recursively copy subdirectory - result = _cp_agfs_dir(process, src_item, dst_item) - if result != 0: - return result - else: - # Show progress - process.stdout.write(f"{src_item} -> {dst_item}\n") - process.stdout.flush() - - # Copy file - read all at once to avoid append overhead - data = process.filesystem.read_file(src_item, stream=False) - process.filesystem.write_file(dst_item, data, append=False) - - return 0 - - except Exception as e: - process.stderr.write(f"cp: {str(e)}\n") - return 1 - - diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/cut.py b/third_party/agfs/agfs-shell/agfs_shell/commands/cut.py deleted file mode 100644 index dd05d1cea..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/cut.py +++ /dev/null @@ -1,233 +0,0 @@ -""" -CUT command - cut out selected portions of each line. -""" - -from typing import List -from ..process import Process -from ..command_decorators import command -from . import register_command - - -def _parse_cut_list(list_str: str) -> List: - """ - Parse a cut list specification (e.g., "1,3,5-7,10-") - Returns a list of (start, end) tuples representing ranges (1-indexed) - """ - ranges = [] - - for part in list_str.split(','): - part = part.strip() - - if '-' in part and not part.startswith('-'): - # Range like "5-7" or "5-" - parts = part.split('-', 1) - start_str = parts[0].strip() - end_str = parts[1].strip() if parts[1] else None - - if not start_str: - raise ValueError(f"invalid range: {part}") - - start = int(start_str) - end = int(end_str) if end_str else None - - if start < 1: - raise ValueError(f"fields and positions are numbered from 1") - - if end is not None and end < start: - raise ValueError(f"invalid range: {part}") - - ranges.append((start, end)) - - elif part.startswith('-'): - # Range like "-5" (from 1 to 5) - end_str = part[1:].strip() - if not end_str: - raise ValueError(f"invalid range: {part}") - - end = int(end_str) - if end < 1: - raise ValueError(f"fields and positions are numbered from 1") - - ranges.append((1, end)) - - else: - # Single number like "3" - num = int(part) - if num < 1: - raise ValueError(f"fields and positions are numbered from 1") - - ranges.append((num, num)) - - return ranges - - -def _cut_fields(process: Process, field_ranges: List, delimiter: str) -> int: - """ - Cut fields from input lines based on field ranges - """ - lines = process.stdin.readlines() - - for line in lines: - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace').rstrip('\n\r') - else: - line_str = line.rstrip('\n\r') - - # Split line by delimiter - fields = line_str.split(delimiter) - - # Extract selected fields - output_fields = [] - for start, end in field_ranges: - if end is None: - # Range like "3-" (from 3 to end) - for i in range(start - 1, len(fields)): - if i < len(fields) and fields[i] not in output_fields: - output_fields.append((i, fields[i])) - else: - # Range like "3-5" or single field "3" - for i in range(start - 1, end): - if i < len(fields) and fields[i] not in [f[1] for f in output_fields if f[0] == i]: - output_fields.append((i, fields[i])) - - # Sort by original field index to maintain order - output_fields.sort(key=lambda x: x[0]) - - # Output the selected fields - if output_fields: - output = delimiter.join([f[1] for f in output_fields]) + '\n' - process.stdout.write(output) - - return 0 - - -def _cut_chars(process: Process, char_ranges: List) -> int: - """ - Cut characters from input lines based on character ranges - """ - lines = process.stdin.readlines() - - for line in lines: - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace').rstrip('\n\r') - else: - line_str = line.rstrip('\n\r') - - # Extract selected characters - output_chars = [] - for start, end in char_ranges: - if end is None: - # Range like "3-" (from 3 to end) - for i in range(start - 1, len(line_str)): - if i < len(line_str): - output_chars.append((i, line_str[i])) - else: - # Range like "3-5" or single character "3" - for i in range(start - 1, end): - if i < len(line_str): - output_chars.append((i, line_str[i])) - - # Sort by original character index to maintain order - output_chars.sort(key=lambda x: x[0]) - - # Remove duplicates while preserving order - seen = set() - unique_chars = [] - for idx, char in output_chars: - if idx not in seen: - seen.add(idx) - unique_chars.append(char) - - # Output the selected characters - if unique_chars: - output = ''.join(unique_chars) + '\n' - process.stdout.write(output) - - return 0 - - -@command() -@register_command('cut') -def cmd_cut(process: Process) -> int: - """ - Cut out selected portions of each line - - Usage: cut [OPTIONS] - - Options: - -f LIST Select only these fields (comma-separated or range) - -d DELIM Use DELIM as field delimiter (default: TAB) - -c LIST Select only these characters (comma-separated or range) - - LIST can be: - N N'th field/character, counted from 1 - N-M From N'th to M'th (inclusive) - N- From N'th to end of line - -M From first to M'th (inclusive) - - Examples: - echo 'a:b:c:d' | cut -d: -f1 # Output: a - echo 'a:b:c:d' | cut -d: -f2-3 # Output: b:c - echo 'a:b:c:d' | cut -d: -f1,3 # Output: a:c - echo 'hello world' | cut -c1-5 # Output: hello - cat /etc/passwd | cut -d: -f1,3 # Get username and UID - """ - # Parse options - fields_str = None - delimiter = '\t' - chars_str = None - - args = process.args[:] - - i = 0 - while i < len(args): - if args[i] == '-f' and i + 1 < len(args): - fields_str = args[i + 1] - i += 2 - elif args[i] == '-d' and i + 1 < len(args): - delimiter = args[i + 1] - i += 2 - elif args[i] == '-c' and i + 1 < len(args): - chars_str = args[i + 1] - i += 2 - elif args[i].startswith('-f'): - # Handle -f1 format - fields_str = args[i][2:] - i += 1 - elif args[i].startswith('-d'): - # Handle -d: format - delimiter = args[i][2:] - i += 1 - elif args[i].startswith('-c'): - # Handle -c1-5 format - chars_str = args[i][2:] - i += 1 - else: - process.stderr.write(f"cut: invalid option -- '{args[i]}'\n") - return 1 - - # Check that either -f or -c is specified (but not both) - if fields_str and chars_str: - process.stderr.write("cut: only one type of list may be specified\n") - return 1 - - if not fields_str and not chars_str: - process.stderr.write("cut: you must specify a list of bytes, characters, or fields\n") - process.stderr.write("Usage: cut -f LIST [-d DELIM] or cut -c LIST\n") - return 1 - - try: - if fields_str: - # Parse field list - field_indices = _parse_cut_list(fields_str) - return _cut_fields(process, field_indices, delimiter) - else: - # Parse character list - char_indices = _parse_cut_list(chars_str) - return _cut_chars(process, char_indices) - - except ValueError as e: - process.stderr.write(f"cut: {e}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/date.py b/third_party/agfs/agfs-shell/agfs_shell/commands/date.py deleted file mode 100644 index 7fd76b3c3..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/date.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -DATE command - display or set the system date and time. -""" - -import subprocess -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('date') -def cmd_date(process: Process) -> int: - """ - Display or set the system date and time by calling the system date command - - Usage: date [OPTION]... [+FORMAT] - - All arguments are passed directly to the system date command. - """ - try: - # Call the system date command with all provided arguments - result = subprocess.run( - ['date'] + process.args, - capture_output=True, - text=False # Use bytes mode to preserve encoding - ) - - # Write stdout from date command to process stdout - if result.stdout: - process.stdout.write(result.stdout) - - # Write stderr from date command to process stderr - if result.stderr: - process.stderr.write(result.stderr) - - return result.returncode - except FileNotFoundError: - process.stderr.write(b"date: command not found\n") - return 127 - except Exception as e: - process.stderr.write(f"date: error: {str(e)}\n".encode('utf-8')) - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/dirname.py b/third_party/agfs/agfs-shell/agfs_shell/commands/dirname.py deleted file mode 100644 index 805985dfc..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/dirname.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -DIRNAME command - extract directory from path. -""" - -import os -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('dirname') -def cmd_dirname(process: Process) -> int: - """ - Extract directory from path - Usage: dirname PATH - - Examples: - dirname /local/path/to/file.txt # /local/path/to - dirname /local/file.txt # /local - dirname file.txt # . - """ - if not process.args: - process.stderr.write("dirname: missing operand\n") - process.stderr.write("Usage: dirname PATH\n") - return 1 - - path = process.args[0] - - # Extract dirname - dirname = os.path.dirname(path) - - # If dirname is empty, use '.' - if not dirname: - dirname = '.' - - process.stdout.write(dirname + '\n') - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/download.py b/third_party/agfs/agfs-shell/agfs_shell/commands/download.py deleted file mode 100644 index 1ad6504a5..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/download.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -DOWNLOAD command - (auto-migrated from builtins.py) -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('download') -def cmd_download(process: Process) -> int: - """ - Download an AGFS file or directory to local filesystem - - Usage: download [-r] - """ - # Parse arguments - recursive = False - args = process.args[:] - - if args and args[0] == '-r': - recursive = True - args = args[1:] - - if len(args) != 2: - process.stderr.write("download: usage: download [-r] \n") - return 1 - - agfs_path = args[0] - local_path = args[1] - - # Resolve agfs_path relative to current working directory - if not agfs_path.startswith('/'): - agfs_path = os.path.join(process.cwd, agfs_path) - agfs_path = os.path.normpath(agfs_path) - - try: - # Check if source path is a directory - info = process.filesystem.get_file_info(agfs_path) - - # Check if destination is a local directory - if os.path.isdir(local_path): - # Destination is a directory, append source filename - source_basename = os.path.basename(agfs_path) - local_path = os.path.join(local_path, source_basename) - - if info.get('isDir', False): - if not recursive: - process.stderr.write(f"download: {agfs_path}: Is a directory (use -r to download recursively)\n") - return 1 - # Download directory recursively - return _download_dir(process, agfs_path, local_path) - else: - # Download single file - return _download_file(process, agfs_path, local_path) - - except FileNotFoundError: - process.stderr.write(f"download: {local_path}: Cannot create file\n") - return 1 - except PermissionError: - process.stderr.write(f"download: {local_path}: Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"download: {agfs_path}: No such file or directory\n") - else: - process.stderr.write(f"download: {error_msg}\n") - return 1 - - -def _download_file(process: Process, agfs_path: str, local_path: str, show_progress: bool = True) -> int: - """Helper: Download a single file from AGFS""" - try: - stream = process.filesystem.read_file(agfs_path, stream=True) - bytes_written = 0 - - with open(local_path, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - bytes_written += len(chunk) - - if show_progress: - process.stdout.write(f"Downloaded {bytes_written} bytes to {local_path}\n") - process.stdout.flush() - return 0 - - except Exception as e: - process.stderr.write(f"download: {agfs_path}: {str(e)}\n") - return 1 - - -def _download_dir(process: Process, agfs_path: str, local_path: str) -> int: - """Helper: Download a directory recursively from AGFS""" - try: - # Create local directory if it doesn't exist - os.makedirs(local_path, exist_ok=True) - - # List AGFS directory - entries = process.filesystem.list_directory(agfs_path) - - for entry in entries: - name = entry['name'] - is_dir = entry.get('isDir', False) - - agfs_item = os.path.join(agfs_path, name) - agfs_item = os.path.normpath(agfs_item) - local_item = os.path.join(local_path, name) - - if is_dir: - # Recursively download subdirectory - result = _download_dir(process, agfs_item, local_item) - if result != 0: - return result - else: - # Download file - result = _download_file(process, agfs_item, local_item) - if result != 0: - return result - - return 0 - - except Exception as e: - process.stderr.write(f"download: {str(e)}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/echo.py b/third_party/agfs/agfs-shell/agfs_shell/commands/echo.py deleted file mode 100644 index 424f13dba..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/echo.py +++ /dev/null @@ -1,19 +0,0 @@ -""" -Echo command - print arguments to stdout. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('echo') -def cmd_echo(process: Process) -> int: - """Echo arguments to stdout""" - if process.args: - output = ' '.join(process.args) + '\n' - process.stdout.write(output) - else: - process.stdout.write('\n') - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/env.py b/third_party/agfs/agfs-shell/agfs_shell/commands/env.py deleted file mode 100644 index cd50be5e3..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/env.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -ENV command - display all environment variables. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('env') -def cmd_env(process: Process) -> int: - """ - Display all environment variables - - Usage: env - """ - if hasattr(process, 'env'): - for key, value in sorted(process.env.items()): - process.stdout.write(f"{key}={value}\n".encode('utf-8')) - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/exit.py b/third_party/agfs/agfs-shell/agfs_shell/commands/exit.py deleted file mode 100644 index c7a950b60..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/exit.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -EXIT command - exit the script with an optional exit code. -""" - -import sys -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('exit') -def cmd_exit(process: Process) -> int: - """ - Exit the script with an optional exit code - - Usage: exit [n] - - Exit with status n (defaults to 0). - In a script, exits the entire script. - In interactive mode, exits the shell. - - Examples: - exit # Exit with status 0 - exit 1 # Exit with status 1 - exit $? # Exit with last command's exit code - """ - exit_code = 0 - if process.args: - try: - exit_code = int(process.args[0]) - except ValueError: - process.stderr.write(f"exit: {process.args[0]}: numeric argument required\n") - exit_code = 2 - - # Exit by raising SystemExit - sys.exit(exit_code) diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/export.py b/third_party/agfs/agfs-shell/agfs_shell/commands/export.py deleted file mode 100644 index 7ac964439..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/export.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -EXPORT command - set or display environment variables. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('export') -def cmd_export(process: Process) -> int: - """ - Set or display environment variables - - Usage: export [VAR=value ...] - """ - if not process.args: - # Display all environment variables (like 'env') - if hasattr(process, 'env'): - for key, value in sorted(process.env.items()): - process.stdout.write(f"{key}={value}\n".encode('utf-8')) - return 0 - - # Set environment variables - for arg in process.args: - if '=' in arg: - var_name, var_value = arg.split('=', 1) - var_name = var_name.strip() - var_value = var_value.strip() - - # Validate variable name - if var_name and var_name.replace('_', '').replace('-', '').isalnum(): - if hasattr(process, 'env'): - process.env[var_name] = var_value - else: - process.stderr.write(f"export: invalid variable name: {var_name}\n") - return 1 - else: - process.stderr.write(f"export: usage: export VAR=value\n") - return 1 - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/false.py b/third_party/agfs/agfs-shell/agfs_shell/commands/false.py deleted file mode 100644 index 033041daf..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/false.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -FALSE command - return failure. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('false') -def cmd_false(process: Process) -> int: - """ - Return failure (exit code 1) - - Usage: false - - Always returns 1 (failure). Useful in scripts and conditionals. - """ - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/grep.py b/third_party/agfs/agfs-shell/agfs_shell/commands/grep.py deleted file mode 100644 index 5ac7912fc..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/grep.py +++ /dev/null @@ -1,200 +0,0 @@ -""" -GREP command - search for patterns in files. -""" - -import re -from io import StringIO -from ..process import Process -from ..command_decorators import command -from . import register_command - - -def _grep_search(process, regex, filename, invert_match, show_line_numbers, - count_only, files_only, show_filename, file_obj=None): - """ - Helper function to search for pattern in a file or stdin - - Returns True if any matches found, False otherwise - """ - if file_obj is None: - # Read from stdin - lines = process.stdin.readlines() - else: - # Read from file object - lines = file_obj.readlines() - - match_count = 0 - line_number = 0 - - for line in lines: - line_number += 1 - - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace') - else: - line_str = line - - # Remove trailing newline for matching - line_clean = line_str.rstrip('\n\r') - - # Check if line matches - matches = bool(regex.search(line_clean)) - if invert_match: - matches = not matches - - if matches: - match_count += 1 - - if files_only: - # Just print filename and stop processing this file - if filename: - process.stdout.write(f"{filename}\n") - return True - - if not count_only: - # Build output line - output_parts = [] - - if show_filename and filename: - output_parts.append(filename) - - if show_line_numbers: - output_parts.append(str(line_number)) - - # Format: filename:linenum:line or just line - if output_parts: - prefix = ':'.join(output_parts) + ':' - process.stdout.write(prefix + line_clean + '\n') - else: - process.stdout.write(line_str if line_str.endswith('\n') else line_clean + '\n') - - # If count_only, print the count - if count_only: - if show_filename and filename: - process.stdout.write(f"{filename}:{match_count}\n") - else: - process.stdout.write(f"{match_count}\n") - - return match_count > 0 - - -@command(supports_streaming=True) -@register_command('grep') -def cmd_grep(process: Process) -> int: - """ - Search for pattern in files or stdin - - Usage: grep [OPTIONS] PATTERN [FILE...] - - Options: - -i Ignore case - -v Invert match (select non-matching lines) - -n Print line numbers - -c Count matching lines - -l Print only filenames with matches - -h Suppress filename prefix (default for single file) - -H Print filename prefix (default for multiple files) - - Examples: - echo 'hello world' | grep hello - grep 'pattern' file.txt - grep -i 'error' *.log - grep -n 'function' code.py - grep -v 'debug' app.log - grep -c 'TODO' *.py - """ - # Parse options - ignore_case = False - invert_match = False - show_line_numbers = False - count_only = False - files_only = False - show_filename = None # None = auto, True = force, False = suppress - - args = process.args[:] - options = [] - - while args and args[0].startswith('-') and args[0] != '-': - opt = args.pop(0) - if opt == '--': - break - - for char in opt[1:]: - if char == 'i': - ignore_case = True - elif char == 'v': - invert_match = True - elif char == 'n': - show_line_numbers = True - elif char == 'c': - count_only = True - elif char == 'l': - files_only = True - elif char == 'h': - show_filename = False - elif char == 'H': - show_filename = True - else: - process.stderr.write(f"grep: invalid option -- '{char}'\n") - return 2 - - # Get pattern - if not args: - process.stderr.write("grep: missing pattern\n") - process.stderr.write("Usage: grep [OPTIONS] PATTERN [FILE...]\n") - return 2 - - pattern = args.pop(0) - files = args - - # Compile regex - try: - flags = re.IGNORECASE if ignore_case else 0 - regex = re.compile(pattern, flags) - except re.error as e: - process.stderr.write(f"grep: invalid pattern: {e}\n") - return 2 - - # Determine if we should show filenames - if show_filename is None: - show_filename = len(files) > 1 - - # Process files or stdin - total_matched = False - - if not files: - # Read from stdin - total_matched = _grep_search( - process, regex, None, invert_match, show_line_numbers, - count_only, files_only, False - ) - else: - # Read from files - for filepath in files: - try: - # Read file content - content = process.filesystem.read_file(filepath) - if isinstance(content, bytes): - content = content.decode('utf-8') - - # Create a file-like object for the content - file_obj = StringIO(content) - - matched = _grep_search( - process, regex, filepath, invert_match, show_line_numbers, - count_only, files_only, show_filename, file_obj - ) - - if matched: - total_matched = True - if files_only: - # Already printed filename, move to next file - continue - - except FileNotFoundError: - process.stderr.write(f"grep: {filepath}: No such file or directory\n") - except Exception as e: - process.stderr.write(f"grep: {filepath}: {e}\n") - - return 0 if total_matched else 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/head.py b/third_party/agfs/agfs-shell/agfs_shell/commands/head.py deleted file mode 100644 index d85b0d75d..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/head.py +++ /dev/null @@ -1,39 +0,0 @@ -""" -HEAD command - output the first part of files. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('head') -def cmd_head(process: Process) -> int: - """ - Output the first part of files - - Usage: head [-n count] - """ - n = 10 # default - - # Parse -n flag - args = process.args[:] - i = 0 - while i < len(args): - if args[i] == '-n' and i + 1 < len(args): - try: - n = int(args[i + 1]) - i += 2 - continue - except ValueError: - process.stderr.write(f"head: invalid number: {args[i + 1]}\n") - return 1 - i += 1 - - # Read lines from stdin - lines = process.stdin.readlines() - for line in lines[:n]: - process.stdout.write(line) - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/help.py b/third_party/agfs/agfs-shell/agfs_shell/commands/help.py deleted file mode 100644 index a4f510fa2..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/help.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -HELP command - display help information for built-in commands. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('help', '?') -def cmd_help(process: Process) -> int: - """ - Display help information for built-in commands - - Usage: ? [command] - help [command] - - Without arguments: List all available commands - With command name: Show detailed help for that command - - Examples: - ? # List all commands - ? ls # Show help for ls command - help grep # Show help for grep command - """ - from . import _COMMANDS as BUILTINS - - if not process.args: - # Show all commands - process.stdout.write("Available built-in commands:\n\n") - - # Get all commands from BUILTINS, sorted alphabetically - # Exclude '[' as it's an alias for 'test' - commands = sorted([cmd for cmd in BUILTINS.keys() if cmd != '[']) - - # Group commands by category for better organization - categories = { - 'File Operations': ['ls', 'tree', 'cat', 'mkdir', 'rm', 'mv', 'cp', 'stat', 'upload', 'download'], - 'Text Processing': ['grep', 'wc', 'head', 'tail', 'sort', 'uniq', 'tr', 'rev', 'cut', 'jq', 'tee'], - 'System': ['pwd', 'cd', 'echo', 'env', 'export', 'unset', 'sleep', 'basename', 'dirname', 'date'], - 'Testing': ['test'], - 'AGFS Management': ['mount', 'plugins'], - 'Control Flow': ['break', 'continue', 'exit', 'return', 'local'], - } - - # Display categorized commands - for category, cmd_list in categories.items(): - category_cmds = [cmd for cmd in cmd_list if cmd in commands] - if category_cmds: - process.stdout.write(f"\033[1;36m{category}:\033[0m\n") - for cmd in category_cmds: - func = BUILTINS[cmd] - # Get first line of docstring as short description - if func.__doc__: - lines = func.__doc__.strip().split('\n') - # Find first non-empty line after initial whitespace - short_desc = "" - for line in lines: - line = line.strip() - if line and not line.startswith('Usage:'): - short_desc = line - break - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m {short_desc}\n") - else: - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m\n") - process.stdout.write("\n") - - # Show uncategorized commands if any - categorized = set() - for cmd_list in categories.values(): - categorized.update(cmd_list) - uncategorized = [cmd for cmd in commands if cmd not in categorized] - if uncategorized: - process.stdout.write(f"\033[1;36mOther:\033[0m\n") - for cmd in uncategorized: - func = BUILTINS[cmd] - if func.__doc__: - lines = func.__doc__.strip().split('\n') - short_desc = "" - for line in lines: - line = line.strip() - if line and not line.startswith('Usage:'): - short_desc = line - break - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m {short_desc}\n") - else: - process.stdout.write(f" \033[1;32m{cmd:12}\033[0m\n") - process.stdout.write("\n") - - process.stdout.write("Type '? ' for detailed help on a specific command.\n") - return 0 - - # Show help for specific command - command_name = process.args[0] - - if command_name not in BUILTINS: - process.stderr.write(f"?: unknown command '{command_name}'\n") - process.stderr.write("Type '?' to see all available commands.\n") - return 1 - - func = BUILTINS[command_name] - - if not func.__doc__: - process.stdout.write(f"No help available for '{command_name}'\n") - return 0 - - # Display the full docstring - process.stdout.write(f"\033[1;36mCommand: {command_name}\033[0m\n\n") - - # Format the docstring nicely - docstring = func.__doc__.strip() - - # Process the docstring to add colors - lines = docstring.split('\n') - for line in lines: - stripped = line.strip() - - # Highlight section headers (Usage:, Options:, Examples:, etc.) - if stripped.endswith(':') and len(stripped.split()) == 1: - process.stdout.write(f"\033[1;33m{stripped}\033[0m\n") - # Highlight option flags - elif stripped.startswith('-'): - # Split option and description - parts = stripped.split(None, 1) - if len(parts) == 2: - option, desc = parts - process.stdout.write(f" \033[1;32m{option:12}\033[0m {desc}\n") - else: - process.stdout.write(f" \033[1;32m{stripped}\033[0m\n") - # Regular line - else: - process.stdout.write(f"{line}\n") - - process.stdout.write("\n") - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/jq.py b/third_party/agfs/agfs-shell/agfs_shell/commands/jq.py deleted file mode 100644 index 4c20bb810..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/jq.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -JQ command - process JSON using jq-like syntax. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(supports_streaming=True) -@register_command('jq') -def cmd_jq(process: Process) -> int: - """ - Process JSON using jq-like syntax - - Usage: - jq FILTER [file...] - cat file.json | jq FILTER - - Examples: - echo '{"name":"test"}' | jq . - cat data.json | jq '.name' - jq '.items[]' data.json - """ - try: - import jq as jq_lib - import json - except ImportError: - process.stderr.write("jq: jq library not installed (run: uv pip install jq)\n") - return 1 - - # First argument is the filter - if not process.args: - process.stderr.write("jq: missing filter expression\n") - process.stderr.write("Usage: jq FILTER [file...]\n") - return 1 - - filter_expr = process.args[0] - input_files = process.args[1:] if len(process.args) > 1 else [] - - try: - # Compile the jq filter - compiled_filter = jq_lib.compile(filter_expr) - except Exception as e: - process.stderr.write(f"jq: compile error: {e}\n") - return 1 - - # Read JSON input - json_data = [] - - if input_files: - # Read from files - for filepath in input_files: - try: - # Read file content - content = process.filesystem.read_file(filepath) - if isinstance(content, bytes): - content = content.decode('utf-8') - - # Parse JSON - data = json.loads(content) - json_data.append(data) - except FileNotFoundError: - process.stderr.write(f"jq: {filepath}: No such file or directory\n") - return 1 - except json.JSONDecodeError as e: - process.stderr.write(f"jq: {filepath}: parse error: {e}\n") - return 1 - except Exception as e: - process.stderr.write(f"jq: {filepath}: {e}\n") - return 1 - else: - # Read from stdin - stdin_data = process.stdin.read() - if isinstance(stdin_data, bytes): - stdin_data = stdin_data.decode('utf-8') - - if not stdin_data.strip(): - process.stderr.write("jq: no input\n") - return 1 - - try: - data = json.loads(stdin_data) - json_data.append(data) - except json.JSONDecodeError as e: - process.stderr.write(f"jq: parse error: {e}\n") - return 1 - - # Apply filter to each JSON input - try: - for data in json_data: - # Run the filter - results = compiled_filter.input(data) - - # Output results - for result in results: - # Pretty print JSON output - output = json.dumps(result, indent=2, ensure_ascii=False) - process.stdout.write(output + '\n') - - return 0 - except Exception as e: - process.stderr.write(f"jq: filter error: {e}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/llm.py b/third_party/agfs/agfs-shell/agfs_shell/commands/llm.py deleted file mode 100644 index f2d34d980..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/llm.py +++ /dev/null @@ -1,356 +0,0 @@ -""" -LLM command - (auto-migrated from builtins.py) -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True, supports_streaming=True) -@register_command('llm') -def cmd_llm(process: Process) -> int: - """ - Interact with LLM models using the llm library - - Usage: llm [OPTIONS] [PROMPT] - echo "text" | llm [OPTIONS] - cat files | llm [OPTIONS] [PROMPT] - cat image.jpg | llm [OPTIONS] [PROMPT] - cat audio.wav | llm [OPTIONS] [PROMPT] - llm --input-file=image.jpg [PROMPT] - - Options: - -m MODEL Specify the model to use (default: gpt-4o-mini) - -s SYSTEM System prompt - -k KEY API key (overrides config/env) - -c CONFIG Path to config file (default: /etc/llm.yaml) - -i FILE Input file (text, image, or audio) - --input-file=FILE Same as -i - - Configuration: - The command reads configuration from: - 1. Environment variables (e.g., OPENAI_API_KEY, ANTHROPIC_API_KEY) - 2. Config file on AGFS (default: /etc/llm.yaml) - 3. Command-line arguments (-k option) - - Config file format (YAML): - model: gpt-4o-mini - api_key: sk-... - system: You are a helpful assistant - - Image Support: - Automatically detects image input (JPEG, PNG, GIF, WebP, BMP) from stdin - and uses vision-capable models for image analysis. - - Audio Support: - Automatically detects audio input (WAV, MP3) from stdin, transcribes it - using OpenAI Whisper API, then processes with the LLM. - - Examples: - # Text prompts - llm "What is 2+2?" - echo "Hello world" | llm - cat *.txt | llm "summarize these files" - echo "Python code" | llm "translate to JavaScript" - - # Image analysis - cat photo.jpg | llm "What's in this image?" - cat screenshot.png | llm "Describe this screenshot in detail" - cat diagram.png | llm - - # Audio transcription and analysis - cat recording.wav | llm "summarize the recording" - cat podcast.mp3 | llm "extract key points" - cat meeting.wav | llm - - # Using --input-file (recommended for binary files) - llm -i photo.jpg "What's in this image?" - llm --input-file=recording.wav "summarize this" - llm -i document.txt "translate to Chinese" - - # Advanced usage - llm -m claude-3-5-sonnet-20241022 "Explain quantum computing" - llm -s "You are a helpful assistant" "How do I install Python?" - """ - import sys - - try: - import llm - except ImportError: - process.stderr.write(b"llm: llm library not installed. Run: pip install llm\n") - return 1 - - # Parse arguments - model_name = None - system_prompt = None - api_key = None - config_path = "/etc/llm.yaml" - input_file = None - prompt_parts = [] - - i = 0 - while i < len(process.args): - arg = process.args[i] - if arg == '-m' and i + 1 < len(process.args): - model_name = process.args[i + 1] - i += 2 - elif arg == '-s' and i + 1 < len(process.args): - system_prompt = process.args[i + 1] - i += 2 - elif arg == '-k' and i + 1 < len(process.args): - api_key = process.args[i + 1] - i += 2 - elif arg == '-c' and i + 1 < len(process.args): - config_path = process.args[i + 1] - i += 2 - elif arg == '-i' and i + 1 < len(process.args): - input_file = process.args[i + 1] - i += 2 - elif arg.startswith('--input-file='): - input_file = arg[len('--input-file='):] - i += 1 - elif arg == '--input-file' and i + 1 < len(process.args): - input_file = process.args[i + 1] - i += 2 - else: - prompt_parts.append(arg) - i += 1 - - # Load configuration from file if it exists - config = {} - try: - if process.filesystem: - config_content = process.filesystem.read_file(config_path) - if config_content: - try: - import yaml - config = yaml.safe_load(config_content.decode('utf-8')) - if not isinstance(config, dict): - config = {} - except ImportError: - # If PyYAML not available, try simple key=value parsing - config_text = config_content.decode('utf-8') - config = {} - for line in config_text.strip().split('\n'): - line = line.strip() - if line and not line.startswith('#') and ':' in line: - key, value = line.split(':', 1) - config[key.strip()] = value.strip() - except Exception: - pass # Ignore config parse errors - except Exception: - pass # Config file doesn't exist or can't be read - - # Set defaults from config or hardcoded - if not model_name: - model_name = config.get('model', 'gpt-4o-mini') - if not system_prompt: - system_prompt = config.get('system') - if not api_key: - api_key = config.get('api_key') - - # Set API key as environment variable (some model plugins don't support key= parameter) - if api_key: - import os - if 'gpt' in model_name.lower() or 'openai' in model_name.lower(): - os.environ['OPENAI_API_KEY'] = api_key - elif 'claude' in model_name.lower() or 'anthropic' in model_name.lower(): - os.environ['ANTHROPIC_API_KEY'] = api_key - - # Helper function to detect if binary data is an image - def is_image(data): - """Detect if binary data is an image by checking magic numbers""" - if not data or len(data) < 8: - return False - # Check common image formats - if data.startswith(b'\xFF\xD8\xFF'): # JPEG - return True - if data.startswith(b'\x89PNG\r\n\x1a\n'): # PNG - return True - if data.startswith(b'GIF87a') or data.startswith(b'GIF89a'): # GIF - return True - if data.startswith(b'RIFF') and data[8:12] == b'WEBP': # WebP - return True - if data.startswith(b'BM'): # BMP - return True - return False - - # Helper function to detect if binary data is audio - def is_audio(data): - """Detect if binary data is audio by checking magic numbers""" - if not data or len(data) < 12: - return False - # Check common audio formats - if data.startswith(b'RIFF') and data[8:12] == b'WAVE': # WAV - return True - if data.startswith(b'ID3') or data.startswith(b'\xFF\xFB') or data.startswith(b'\xFF\xF3') or data.startswith(b'\xFF\xF2'): # MP3 - return True - return False - - # Helper function to transcribe audio using OpenAI Whisper - def transcribe_audio(audio_data, api_key=None): - """Transcribe audio data using OpenAI Whisper API""" - try: - import openai - import tempfile - import os - except ImportError: - return None, "openai library not installed. Run: pip install openai" - - # Determine file extension based on audio format - if audio_data.startswith(b'RIFF') and audio_data[8:12] == b'WAVE': - ext = '.wav' - else: - ext = '.mp3' - - # Write audio data to temporary file - with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp_file: - tmp_file.write(audio_data) - tmp_path = tmp_file.name - - try: - # Create OpenAI client - if api_key: - client = openai.OpenAI(api_key=api_key) - else: - client = openai.OpenAI() # Uses OPENAI_API_KEY from environment - - # Transcribe audio - with open(tmp_path, 'rb') as audio_file: - transcript = client.audio.transcriptions.create( - model="whisper-1", - file=audio_file - ) - - return transcript.text, None - except Exception as e: - return None, f"Failed to transcribe audio: {str(e)}" - finally: - # Clean up temporary file - try: - os.unlink(tmp_path) - except Exception: - pass - - # Get input content: from --input-file or stdin - stdin_binary = None - stdin_text = None - is_in_pipeline = False - - # If input file is specified, read from file - if input_file: - try: - if process.filesystem: - stdin_binary = process.filesystem.read_file(input_file) - else: - with open(input_file, 'rb') as f: - stdin_binary = f.read() - if not stdin_binary: - process.stderr.write(f"llm: input file is empty: {input_file}\n".encode('utf-8')) - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"llm: {input_file}: No such file or directory\n".encode('utf-8')) - else: - process.stderr.write(f"llm: failed to read {input_file}: {error_msg}\n".encode('utf-8')) - return 1 - else: - # Use read() instead of get_value() to properly support streaming pipelines - stdin_binary = process.stdin.read() - - # Debug: check if we're in a pipeline but got empty stdin - is_in_pipeline = hasattr(process.stdin, 'pipe') # StreamingInputStream has pipe attribute - - if not stdin_binary: - # Try to read from real stdin (but don't block if not available) - try: - import select - if select.select([sys.stdin], [], [], 0.0)[0]: - stdin_binary = sys.stdin.buffer.read() - except Exception: - pass # No stdin available - - # Check if stdin is an image or audio - is_stdin_image = False - is_stdin_audio = False - if stdin_binary: - is_stdin_image = is_image(stdin_binary) - if not is_stdin_image: - is_stdin_audio = is_audio(stdin_binary) - if is_stdin_audio: - # Transcribe audio - transcript_text, error = transcribe_audio(stdin_binary, api_key) - if error: - process.stderr.write(f"llm: {error}\n".encode('utf-8')) - return 1 - stdin_text = transcript_text - else: - # Try to decode as text - try: - stdin_text = stdin_binary.decode('utf-8').strip() - except UnicodeDecodeError: - # Binary data but not an image or audio we recognize - process.stderr.write(b"llm: stdin contains binary data that is not a recognized image or audio format\n") - return 1 - - # Get prompt from args - prompt_text = None - if prompt_parts: - prompt_text = ' '.join(prompt_parts) - - # Warn if we're in a pipeline but got empty stdin (likely indicates an error in previous command) - if is_in_pipeline and not stdin_binary and not stdin_text and prompt_text: - process.stderr.write(b"llm: warning: received empty input from pipeline, proceeding with prompt only\n") - - # Determine the final prompt and attachments - attachments = [] - if is_stdin_image: - # Image input: use as attachment - attachments.append(llm.Attachment(content=stdin_binary)) - if prompt_text: - full_prompt = prompt_text - else: - full_prompt = "Describe this image" - elif stdin_text and prompt_text: - # Both text stdin and prompt: stdin is context, prompt is the question/instruction - full_prompt = f"{stdin_text}\n\n===\n\n{prompt_text}" - elif stdin_text: - # Only text stdin: use it as the prompt - full_prompt = stdin_text - elif prompt_text: - # Only prompt: use it as-is - full_prompt = prompt_text - else: - # Neither: error - process.stderr.write(b"llm: no prompt provided\n") - return 1 - - # Get the model - try: - model = llm.get_model(model_name) - except Exception as e: - error_msg = f"llm: failed to get model '{model_name}': {str(e)}\n" - process.stderr.write(error_msg.encode('utf-8')) - return 1 - - # Prepare prompt kwargs (don't pass key - use environment variable instead) - prompt_kwargs = {} - if system_prompt: - prompt_kwargs['system'] = system_prompt - if attachments: - prompt_kwargs['attachments'] = attachments - - # Execute the prompt - try: - response = model.prompt(full_prompt, **prompt_kwargs) - output = response.text() - process.stdout.write(output.encode('utf-8')) - if not output.endswith('\n'): - process.stdout.write(b'\n') - return 0 - except Exception as e: - error_msg = f"llm: error: {str(e)}\n" - process.stderr.write(error_msg.encode('utf-8')) - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/local.py b/third_party/agfs/agfs-shell/agfs_shell/commands/local.py deleted file mode 100644 index e12acfa66..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/local.py +++ /dev/null @@ -1,59 +0,0 @@ -""" -LOCAL command - declare local variables (only valid within functions). -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('local') -def cmd_local(process: Process) -> int: - """ - Declare local variables (only valid within functions) - - Usage: local VAR=value [VAR2=value2 ...] - - Examples: - local name="Alice" - local count=0 - local path=/tmp/data - """ - # Check if we have any local scopes (we're inside a function) - # Note: This check needs to be done via env since we don't have direct access to shell - # We'll use a special marker in env to track function depth - if not process.env.get('_function_depth'): - process.stderr.write("local: can only be used in a function\n") - return 1 - - if not process.args: - process.stderr.write("local: usage: local VAR=value [VAR2=value2 ...]\n") - return 2 - - # Process each variable assignment - for arg in process.args: - if '=' not in arg: - process.stderr.write(f"local: {arg}: not a valid identifier\n") - return 1 - - parts = arg.split('=', 1) - var_name = parts[0].strip() - var_value = parts[1] if len(parts) > 1 else '' - - # Validate variable name - if not var_name or not var_name.replace('_', '').isalnum(): - process.stderr.write(f"local: {var_name}: not a valid identifier\n") - return 1 - - # Remove outer quotes if present - if len(var_value) >= 2: - if (var_value[0] == '"' and var_value[-1] == '"') or \ - (var_value[0] == "'" and var_value[-1] == "'"): - var_value = var_value[1:-1] - - # Mark this variable as local by using a special prefix in env - # This is a workaround since we don't have direct access to shell.local_scopes - process.env[f'_local_{var_name}'] = var_value - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/ls.py b/third_party/agfs/agfs-shell/agfs_shell/commands/ls.py deleted file mode 100644 index ba98c83fa..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/ls.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -LS command - list directory contents. -""" - -import os -from ..process import Process -from ..command_decorators import command -from ..utils.formatters import mode_to_rwx, human_readable_size -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('ls') -def cmd_ls(process: Process) -> int: - """ - List directory contents - - Usage: ls [-l] [-h] [path...] - - Options: - -l Use long listing format - -h Print human-readable sizes (e.g., 1K, 234M, 2G) - """ - # Parse arguments - long_format = False - human_readable_flag = False - paths = [] - - for arg in process.args: - if arg.startswith('-') and arg != '-': - # Handle combined flags like -lh - if 'l' in arg: - long_format = True - if 'h' in arg: - human_readable_flag = True - else: - paths.append(arg) - - # Default to current working directory if no paths specified - if not paths: - cwd = getattr(process, 'cwd', '/') - paths = [cwd] - - if not process.filesystem: - process.stderr.write("ls: filesystem not available\n") - return 1 - - # Helper function to format file info - def format_file_info(file_info, display_name=None): - """Format a single file info dict for output""" - name = display_name if display_name else file_info.get('name', '') - is_dir = file_info.get('isDir', False) or file_info.get('type') == 'directory' - size = file_info.get('size', 0) - - if long_format: - # Long format output similar to ls -l - file_type = 'd' if is_dir else '-' - - # Get mode/permissions - mode_str = file_info.get('mode', '') - if mode_str and isinstance(mode_str, str) and len(mode_str) >= 9: - # Already in rwxr-xr-x format - perms = mode_str[:9] - elif mode_str and isinstance(mode_str, int): - # Convert octal mode to rwx format - perms = mode_to_rwx(mode_str) - else: - # Default permissions - perms = 'rwxr-xr-x' if is_dir else 'rw-r--r--' - - # Get modification time - mtime = file_info.get('modTime', file_info.get('mtime', '')) - if mtime: - # Format timestamp (YYYY-MM-DD HH:MM:SS) - if 'T' in mtime: - # ISO format: 2025-11-18T22:00:25Z - mtime = mtime.replace('T', ' ').replace('Z', '').split('.')[0] - elif len(mtime) > 19: - # Truncate to 19 chars if too long - mtime = mtime[:19] - else: - mtime = '0000-00-00 00:00:00' - - # Format: permissions size date time name - # Add color for directories (blue) - if is_dir: - # Blue color for directories - colored_name = f"\033[1;34m{name}/\033[0m" - else: - colored_name = name - - # Format size based on human_readable flag - if human_readable_flag: - size_str = f"{human_readable_size(size):>8}" - else: - size_str = f"{size:>8}" - - return f"{file_type}{perms} {size_str} {mtime} {colored_name}\n" - else: - # Simple formatting - if is_dir: - # Blue color for directories - return f"\033[1;34m{name}/\033[0m\n" - else: - return f"{name}\n" - - exit_code = 0 - - try: - # Process each path argument - for path in paths: - try: - # First, get info about the path to determine if it's a file or directory - path_info = process.filesystem.get_file_info(path) - is_directory = path_info.get('isDir', False) or path_info.get('type') == 'directory' - - if is_directory: - # It's a directory - list its contents - files = process.filesystem.list_directory(path) - - # Show directory name if multiple paths - if len(paths) > 1: - process.stdout.write(f"{path}:\n".encode('utf-8')) - - for file_info in files: - output = format_file_info(file_info) - process.stdout.write(output.encode('utf-8')) - - # Add blank line between directories if multiple paths - if len(paths) > 1: - process.stdout.write(b"\n") - else: - # It's a file - display info about the file itself - basename = os.path.basename(path) - output = format_file_info(path_info, display_name=basename) - process.stdout.write(output.encode('utf-8')) - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"ls: {path}: No such file or directory\n") - else: - process.stderr.write(f"ls: {path}: {error_msg}\n") - exit_code = 1 - - return exit_code - except Exception as e: - error_msg = str(e) - process.stderr.write(f"ls: {error_msg}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/mkdir.py b/third_party/agfs/agfs-shell/agfs_shell/commands/mkdir.py deleted file mode 100644 index e579608f5..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/mkdir.py +++ /dev/null @@ -1,35 +0,0 @@ -""" -MKDIR command - create directory. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('mkdir') -def cmd_mkdir(process: Process) -> int: - """ - Create directory - - Usage: mkdir path - """ - if not process.args: - process.stderr.write("mkdir: missing operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("mkdir: filesystem not available\n") - return 1 - - path = process.args[0] - - try: - # Use AGFS client to create directory - process.filesystem.client.mkdir(path) - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"mkdir: {path}: {error_msg}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/mount.py b/third_party/agfs/agfs-shell/agfs_shell/commands/mount.py deleted file mode 100644 index 148bb8139..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/mount.py +++ /dev/null @@ -1,107 +0,0 @@ -""" -MOUNT command - mount a plugin dynamically or list mounted filesystems. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('mount') -def cmd_mount(process: Process) -> int: - """ - Mount a plugin dynamically or list mounted filesystems - - Usage: mount [ [key=value ...]] - - Without arguments: List all mounted filesystems - With arguments: Mount a new filesystem - - Examples: - mount # List all mounted filesystems - mount memfs /test/mem - mount sqlfs /test/db backend=sqlite db_path=/tmp/test.db - mount s3fs /test/s3 bucket=my-bucket region=us-west-1 access_key_id=xxx secret_access_key=yyy - mount proxyfs /remote "base_url=http://workstation:8080/api/v1" # Quote URLs with colons - """ - if not process.filesystem: - process.stderr.write("mount: filesystem not available\n") - return 1 - - # No arguments - list mounted filesystems - if len(process.args) == 0: - try: - mounts_list = process.filesystem.client.mounts() - - if not mounts_list: - process.stdout.write("No plugins mounted\n") - return 0 - - # Print mounts in Unix mount style: on (options...) - for mount in mounts_list: - path = mount.get("path", "") - plugin = mount.get("pluginName", "") - config = mount.get("config", {}) - - # Build options string from config - options = [] - for key, value in config.items(): - # Hide sensitive keys - if key in ["secret_access_key", "password", "token"]: - options.append(f"{key}=***") - else: - # Convert value to string, truncate if too long - value_str = str(value) - if len(value_str) > 50: - value_str = value_str[:47] + "..." - options.append(f"{key}={value_str}") - - # Format output line - if options: - options_str = ", ".join(options) - process.stdout.write(f"{plugin} on {path} (plugin: {plugin}, {options_str})\n") - else: - process.stdout.write(f"{plugin} on {path} (plugin: {plugin})\n") - - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"mount: {error_msg}\n") - return 1 - - # With arguments - mount a new filesystem - if len(process.args) < 2: - process.stderr.write("mount: missing operands\n") - process.stderr.write("Usage: mount [key=value ...]\n") - process.stderr.write("\nExamples:\n") - process.stderr.write(" mount memfs /test/mem\n") - process.stderr.write(" mount sqlfs /test/db backend=sqlite db_path=/tmp/test.db\n") - process.stderr.write(" mount s3fs /test/s3 bucket=my-bucket region=us-west-1\n") - process.stderr.write(' mount proxyfs /remote "base_url=http://workstation:8080/api/v1" # Quote URLs\n') - return 1 - - fstype = process.args[0] - path = process.args[1] - config_args = process.args[2:] if len(process.args) > 2 else [] - - # Parse key=value config arguments - config = {} - for arg in config_args: - if '=' in arg: - key, value = arg.split('=', 1) - config[key.strip()] = value.strip() - else: - process.stderr.write(f"mount: invalid config argument: {arg}\n") - process.stderr.write("Config arguments must be in key=value format\n") - return 1 - - try: - # Use AGFS client to mount the plugin - process.filesystem.client.mount(fstype, path, config) - process.stdout.write(f"Mounted {fstype} at {path}\n") - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"mount: {error_msg}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/mv.py b/third_party/agfs/agfs-shell/agfs_shell/commands/mv.py deleted file mode 100644 index be305ead2..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/mv.py +++ /dev/null @@ -1,305 +0,0 @@ -""" -MV command - (auto-migrated from builtins.py) -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('mv') -def cmd_mv(process: Process) -> int: - """ - Move (rename) files and directories - - Usage: mv [OPTIONS] SOURCE DEST - mv [OPTIONS] SOURCE... DIRECTORY - - Options: - -i Prompt before overwrite (interactive mode) - -n Do not overwrite an existing file - -f Force overwrite without prompting (default) - - Path formats: - - AGFS path (default) - local: - Local filesystem path - - Examples: - mv file.txt newname.txt # Rename within AGFS - mv file1.txt file2.txt dir/ # Move multiple files to directory - mv local:file.txt /agfs/path/ # Move from local to AGFS - mv /agfs/file.txt local:~/Downloads/ # Move from AGFS to local - mv -i file.txt existing.txt # Prompt before overwriting - mv -n file.txt existing.txt # Don't overwrite if exists - """ - # Parse options - interactive = False - no_clobber = False - force = True # Default behavior - args = process.args[:] - sources = [] - - i = 0 - while i < len(args): - if args[i] == '-i': - interactive = True - force = False - i += 1 - elif args[i] == '-n': - no_clobber = True - force = False - i += 1 - elif args[i] == '-f': - force = True - interactive = False - no_clobber = False - i += 1 - elif args[i].startswith('-'): - # Handle combined flags like -in - for char in args[i][1:]: - if char == 'i': - interactive = True - force = False - elif char == 'n': - no_clobber = True - force = False - elif char == 'f': - force = True - interactive = False - no_clobber = False - else: - process.stderr.write(f"mv: invalid option -- '{char}'\n") - return 1 - i += 1 - else: - sources.append(args[i]) - i += 1 - - # Need at least source and dest - if len(sources) < 2: - process.stderr.write("mv: missing file operand\n") - process.stderr.write("Usage: mv [OPTIONS] SOURCE DEST\n") - process.stderr.write(" mv [OPTIONS] SOURCE... DIRECTORY\n") - return 1 - - dest = sources.pop() - - # Parse source and dest to determine if local or AGFS - source_paths = [] - for src in sources: - is_local = src.startswith('local:') - path = src[6:] if is_local else src - source_paths.append({'path': path, 'is_local': is_local, 'original': src}) - - dest_is_local = dest.startswith('local:') - dest_path = dest[6:] if dest_is_local else dest - - # Resolve AGFS paths relative to cwd - if not dest_is_local and not dest_path.startswith('/'): - dest_path = os.path.join(process.cwd, dest_path) - dest_path = os.path.normpath(dest_path) - - for src_info in source_paths: - if not src_info['is_local'] and not src_info['path'].startswith('/'): - src_info['path'] = os.path.join(process.cwd, src_info['path']) - src_info['path'] = os.path.normpath(src_info['path']) - - # Check if moving multiple files - if len(source_paths) > 1: - # Multiple sources - dest must be a directory - if dest_is_local: - if not os.path.isdir(dest_path): - process.stderr.write(f"mv: target '{dest}' is not a directory\n") - return 1 - else: - try: - dest_info = process.filesystem.get_file_info(dest_path) - if not (dest_info.get('isDir', False) or dest_info.get('type') == 'directory'): - process.stderr.write(f"mv: target '{dest}' is not a directory\n") - return 1 - except: - process.stderr.write(f"mv: target '{dest}' is not a directory\n") - return 1 - - # Move each source to dest directory - for src_info in source_paths: - result = _mv_single( - process, src_info['path'], dest_path, - src_info['is_local'], dest_is_local, - interactive, no_clobber, force, - src_info['original'], dest - ) - if result != 0: - return result - else: - # Single source - src_info = source_paths[0] - return _mv_single( - process, src_info['path'], dest_path, - src_info['is_local'], dest_is_local, - interactive, no_clobber, force, - src_info['original'], dest - ) - - return 0 - - -def _mv_single(process, source_path, dest_path, source_is_local, dest_is_local, - interactive, no_clobber, force, source_display, dest_display): - """ - Move a single file or directory - - Returns 0 on success, non-zero on failure - """ - import sys - - # Determine final destination path - final_dest = dest_path - - # Check if destination exists and is a directory - dest_exists = False - dest_is_dir = False - - if dest_is_local: - dest_exists = os.path.exists(dest_path) - dest_is_dir = os.path.isdir(dest_path) - else: - try: - dest_info = process.filesystem.get_file_info(dest_path) - dest_exists = True - dest_is_dir = dest_info.get('isDir', False) or dest_info.get('type') == 'directory' - except: - dest_exists = False - dest_is_dir = False - - # If dest is a directory, append source filename - if dest_exists and dest_is_dir: - source_basename = os.path.basename(source_path) - if dest_is_local: - final_dest = os.path.join(dest_path, source_basename) - else: - final_dest = os.path.join(dest_path, source_basename) - final_dest = os.path.normpath(final_dest) - - # Check if final destination exists - final_dest_exists = False - if dest_is_local: - final_dest_exists = os.path.exists(final_dest) - else: - try: - process.filesystem.get_file_info(final_dest) - final_dest_exists = True - except: - final_dest_exists = False - - # Handle overwrite protection - if final_dest_exists: - if no_clobber: - # Don't overwrite, silently skip - return 0 - - if interactive: - # Prompt user - process.stderr.write(f"mv: overwrite '{final_dest}'? (y/n) ") - process.stderr.flush() - try: - response = sys.stdin.readline().strip().lower() - if response not in ['y', 'yes']: - return 0 - except: - return 0 - - # Perform the move operation based on source and dest types - try: - if source_is_local and dest_is_local: - # Local to local - use os.rename or shutil.move - import shutil - shutil.move(source_path, final_dest) - return 0 - - elif source_is_local and not dest_is_local: - # Local to AGFS - upload then delete local - if os.path.isdir(source_path): - # Move directory - result = _upload_dir(process, source_path, final_dest) - if result == 0: - # Delete local directory after successful upload - import shutil - shutil.rmtree(source_path) - return result - else: - # Move file - with open(source_path, 'rb') as f: - data = f.read() - process.filesystem.write_file(final_dest, data, append=False) - # Delete local file after successful upload - os.remove(source_path) - return 0 - - elif not source_is_local and dest_is_local: - # AGFS to local - download then delete AGFS - source_info = process.filesystem.get_file_info(source_path) - is_dir = source_info.get('isDir', False) or source_info.get('type') == 'directory' - - if is_dir: - # Move directory - result = _download_dir(process, source_path, final_dest) - if result == 0: - # Delete AGFS directory after successful download - process.filesystem.client.rm(source_path, recursive=True) - return result - else: - # Move file - stream = process.filesystem.read_file(source_path, stream=True) - with open(final_dest, 'wb') as f: - for chunk in stream: - if chunk: - f.write(chunk) - # Delete AGFS file after successful download - process.filesystem.client.rm(source_path, recursive=False) - return 0 - - else: - # AGFS to AGFS - use rename if supported, otherwise copy + delete - # Check if source exists - source_info = process.filesystem.get_file_info(source_path) - - # Try to use AGFS rename/move if available - if hasattr(process.filesystem.client, 'rename'): - process.filesystem.client.rename(source_path, final_dest) - elif hasattr(process.filesystem.client, 'mv'): - process.filesystem.client.mv(source_path, final_dest) - else: - # Fallback: copy then delete - is_dir = source_info.get('isDir', False) or source_info.get('type') == 'directory' - - if is_dir: - # Copy directory recursively - result = _cp_agfs_dir(process, source_path, final_dest) - if result != 0: - return result - # Delete source directory - process.filesystem.client.rm(source_path, recursive=True) - else: - # Copy file - data = process.filesystem.read_file(source_path, stream=False) - process.filesystem.write_file(final_dest, data, append=False) - # Delete source file - process.filesystem.client.rm(source_path, recursive=False) - - return 0 - - except FileNotFoundError: - process.stderr.write(f"mv: cannot stat '{source_display}': No such file or directory\n") - return 1 - except PermissionError: - process.stderr.write(f"mv: cannot move '{source_display}': Permission denied\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"mv: cannot stat '{source_display}': No such file or directory\n") - else: - process.stderr.write(f"mv: cannot move '{source_display}' to '{dest_display}': {error_msg}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/plugins.py b/third_party/agfs/agfs-shell/agfs_shell/commands/plugins.py deleted file mode 100644 index c595d7c44..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/plugins.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -PLUGINS command - manage AGFS plugins. -""" - -import os -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('plugins') -def cmd_plugins(process: Process) -> int: - """ - Manage AGFS plugins - - Usage: plugins [arguments] - - Subcommands: - list [-v] List all plugins (builtin and external) - load Load external plugin from AGFS or HTTP(S) - unload Unload external plugin - - Options: - -v Show detailed configuration parameters - - Path formats for load: - - Load from AGFS (relative to current directory) - - Load from AGFS (absolute path) - http(s):// - Load from HTTP(S) URL - - Examples: - plugins list # List all plugins - plugins list -v # List with config details - plugins load /mnt/plugins/myplugin.so # Load from AGFS (absolute) - plugins load myplugin.so # Load from current directory - plugins load ../plugins/myplugin.so # Load from relative path - plugins load https://example.com/myplugin.so # Load from HTTP(S) - plugins unload /mnt/plugins/myplugin.so # Unload plugin - """ - if not process.filesystem: - process.stderr.write("plugins: filesystem not available\n") - return 1 - - # No arguments - show usage - if len(process.args) == 0: - process.stderr.write("Usage: plugins [arguments]\n") - process.stderr.write("\nSubcommands:\n") - process.stderr.write(" list - List all plugins (builtin and external)\n") - process.stderr.write(" load - Load external plugin\n") - process.stderr.write(" unload - Unload external plugin\n") - process.stderr.write("\nPath formats for load:\n") - process.stderr.write(" - Load from AGFS (relative to current directory)\n") - process.stderr.write(" - Load from AGFS (absolute path)\n") - process.stderr.write(" http(s):// - Load from HTTP(S) URL\n") - process.stderr.write("\nExamples:\n") - process.stderr.write(" plugins list\n") - process.stderr.write(" plugins load /mnt/plugins/myplugin.so # Absolute path\n") - process.stderr.write(" plugins load myplugin.so # Current directory\n") - process.stderr.write(" plugins load ../plugins/myplugin.so # Relative path\n") - process.stderr.write(" plugins load https://example.com/myplugin.so # HTTP(S) URL\n") - return 1 - - # Handle plugin subcommands - subcommand = process.args[0].lower() - - if subcommand == "load": - if len(process.args) < 2: - process.stderr.write("Usage: plugins load \n") - process.stderr.write("\nPath formats:\n") - process.stderr.write(" - Load from AGFS (relative to current directory)\n") - process.stderr.write(" - Load from AGFS (absolute path)\n") - process.stderr.write(" http(s):// - Load from HTTP(S) URL\n") - process.stderr.write("\nExamples:\n") - process.stderr.write(" plugins load /mnt/plugins/myplugin.so # Absolute path\n") - process.stderr.write(" plugins load myplugin.so # Current directory\n") - process.stderr.write(" plugins load ../plugins/myplugin.so # Relative path\n") - process.stderr.write(" plugins load https://example.com/myplugin.so # HTTP(S) URL\n") - return 1 - - path = process.args[1] - - # Determine path type - is_http = path.startswith('http://') or path.startswith('https://') - - # Process path based on type - if is_http: - # HTTP(S) URL: use as-is, server will download it - library_path = path - else: - # AGFS path: resolve relative paths and add agfs:// prefix - # Resolve relative paths to absolute paths - if not path.startswith('/'): - # Relative path - resolve based on current working directory - cwd = getattr(process, 'cwd', '/') - path = os.path.normpath(os.path.join(cwd, path)) - library_path = f"agfs://{path}" - - try: - # Load the plugin - result = process.filesystem.client.load_plugin(library_path) - plugin_name = result.get("plugin_name", "unknown") - process.stdout.write(f"Loaded external plugin: {plugin_name}\n") - process.stdout.write(f" Source: {path}\n") - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"plugins load: {error_msg}\n") - return 1 - - elif subcommand == "unload": - if len(process.args) < 2: - process.stderr.write("Usage: plugins unload \n") - return 1 - - library_path = process.args[1] - - try: - process.filesystem.client.unload_plugin(library_path) - process.stdout.write(f"Unloaded external plugin: {library_path}\n") - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"plugins unload: {error_msg}\n") - return 1 - - elif subcommand == "list": - try: - # Check for verbose flag - verbose = '-v' in process.args[1:] or '--verbose' in process.args[1:] - - # Use new API to get detailed plugin information - plugins_info = process.filesystem.client.get_plugins_info() - - # Separate builtin and external plugins - builtin_plugins = [p for p in plugins_info if not p.get('is_external', False)] - external_plugins = [p for p in plugins_info if p.get('is_external', False)] - - # Display builtin plugins - if builtin_plugins: - process.stdout.write(f"Builtin Plugins: ({len(builtin_plugins)})\n") - for plugin in sorted(builtin_plugins, key=lambda x: x.get('name', '')): - plugin_name = plugin.get('name', 'unknown') - mounted_paths = plugin.get('mounted_paths', []) - config_params = plugin.get('config_params', []) - - if mounted_paths: - mount_list = [] - for mount in mounted_paths: - path = mount.get('path', '') - config = mount.get('config', {}) - if config: - mount_list.append(f"{path} (with config)") - else: - mount_list.append(path) - process.stdout.write(f" {plugin_name:20} -> {', '.join(mount_list)}\n") - else: - process.stdout.write(f" {plugin_name:20} (not mounted)\n") - - # Show config params if verbose and available - if verbose and config_params: - process.stdout.write(f" Config parameters:\n") - for param in config_params: - req = "*" if param.get('required', False) else " " - name = param.get('name', '') - ptype = param.get('type', '') - default = param.get('default', '') - desc = param.get('description', '') - default_str = f" (default: {default})" if default else "" - process.stdout.write(f" {req} {name:20} {ptype:10} {desc}{default_str}\n") - - process.stdout.write("\n") - - # Display external plugins - if external_plugins: - process.stdout.write(f"External Plugins: ({len(external_plugins)})\n") - for plugin in sorted(external_plugins, key=lambda x: x.get('name', '')): - plugin_name = plugin.get('name', 'unknown') - library_path = plugin.get('library_path', '') - mounted_paths = plugin.get('mounted_paths', []) - config_params = plugin.get('config_params', []) - - # Extract just the filename for display - filename = os.path.basename(library_path) if library_path else plugin_name - process.stdout.write(f" {filename}\n") - process.stdout.write(f" Plugin name: {plugin_name}\n") - - if mounted_paths: - mount_list = [] - for mount in mounted_paths: - path = mount.get('path', '') - config = mount.get('config', {}) - if config: - mount_list.append(f"{path} (with config)") - else: - mount_list.append(path) - process.stdout.write(f" Mounted at: {', '.join(mount_list)}\n") - else: - process.stdout.write(f" (Not currently mounted)\n") - - # Show config params if verbose and available - if verbose and config_params: - process.stdout.write(f" Config parameters:\n") - for param in config_params: - req = "*" if param.get('required', False) else " " - name = param.get('name', '') - ptype = param.get('type', '') - default = param.get('default', '') - desc = param.get('description', '') - default_str = f" (default: {default})" if default else "" - process.stdout.write(f" {req} {name:20} {ptype:10} {desc}{default_str}\n") - else: - process.stdout.write("No external plugins loaded\n") - - return 0 - except Exception as e: - error_msg = str(e) - process.stderr.write(f"plugins list: {error_msg}\n") - return 1 - - else: - process.stderr.write(f"plugins: unknown subcommand: {subcommand}\n") - process.stderr.write("\nUsage:\n") - process.stderr.write(" plugins list - List all plugins\n") - process.stderr.write(" plugins load - Load external plugin\n") - process.stderr.write(" plugins unload - Unload external plugin\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/pwd.py b/third_party/agfs/agfs-shell/agfs_shell/commands/pwd.py deleted file mode 100644 index b59a7009d..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/pwd.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -PWD command - print working directory. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('pwd') -def cmd_pwd(process: Process) -> int: - """ - Print working directory - - Usage: pwd - """ - # Get cwd from process metadata if available - cwd = getattr(process, 'cwd', '/') - process.stdout.write(f"{cwd}\n".encode('utf-8')) - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/return_cmd.py b/third_party/agfs/agfs-shell/agfs_shell/commands/return_cmd.py deleted file mode 100644 index bf69dbd6c..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/return_cmd.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -RETURN command - return from a function with an optional exit status. - -Note: Module name is return_cmd.py because 'return' is a Python keyword. -""" - -from ..process import Process -from ..command_decorators import command -from ..control_flow import ReturnException -from ..exit_codes import EXIT_CODE_RETURN -from . import register_command - - -@command() -@register_command('return') -def cmd_return(process: Process) -> int: - """ - Return from a function with an optional exit status - - Usage: return [n] - - Examples: - return # Return with status 0 - return 1 # Return with status 1 - return $? # Return with last command's status - """ - # Parse exit code - exit_code = 0 - if process.args: - try: - exit_code = int(process.args[0]) - except ValueError: - process.stderr.write(f"return: {process.args[0]}: numeric argument required\n".encode()) - return 2 - - # Store return value in env for legacy code path - process.env['_return_value'] = str(exit_code) - - # Raise exception to be caught by executor or execute_function - raise ReturnException(exit_code=exit_code) diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/rev.py b/third_party/agfs/agfs-shell/agfs_shell/commands/rev.py deleted file mode 100644 index 23fcf00b0..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/rev.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -REV command - reverse lines character-wise. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('rev') -def cmd_rev(process: Process) -> int: - """ - Reverse lines character-wise - - Usage: rev - - Examples: - echo 'hello' | rev # Output: olleh - echo 'abc:def' | rev # Output: fed:cba - ls -l | rev | cut -d' ' -f1 | rev # Extract filenames from ls -l - """ - lines = process.stdin.readlines() - - for line in lines: - # Handle both str and bytes - if isinstance(line, bytes): - line_str = line.decode('utf-8', errors='replace') - else: - line_str = line - - # Remove trailing newline, reverse, add newline back - line_clean = line_str.rstrip('\n\r') - reversed_line = line_clean[::-1] - process.stdout.write(reversed_line + '\n') - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/rm.py b/third_party/agfs/agfs-shell/agfs_shell/commands/rm.py deleted file mode 100644 index 4a368a41a..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/rm.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -RM command - remove file or directory. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('rm') -def cmd_rm(process: Process) -> int: - """ - Remove file or directory - - Usage: rm [-r] path... - """ - if not process.args: - process.stderr.write("rm: missing operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("rm: filesystem not available\n") - return 1 - - recursive = False - paths = [] - - for arg in process.args: - if arg == '-r' or arg == '-rf': - recursive = True - else: - paths.append(arg) - - if not paths: - process.stderr.write("rm: missing file operand\n") - return 1 - - exit_code = 0 - - for path in paths: - try: - # Use AGFS client to remove file/directory - process.filesystem.client.rm(path, recursive=recursive) - except Exception as e: - error_msg = str(e) - process.stderr.write(f"rm: {path}: {error_msg}\n") - exit_code = 1 - - return exit_code diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/sleep.py b/third_party/agfs/agfs-shell/agfs_shell/commands/sleep.py deleted file mode 100644 index 24c588941..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/sleep.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -SLEEP command - pause execution for specified seconds. -""" - -import time -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('sleep') -def cmd_sleep(process: Process) -> int: - """ - Pause execution for specified seconds - - Usage: sleep SECONDS - - Examples: - sleep 1 # Sleep for 1 second - sleep 0.5 # Sleep for 0.5 seconds - sleep 5 # Sleep for 5 seconds - """ - if not process.args: - process.stderr.write("sleep: missing operand\n") - process.stderr.write("Usage: sleep SECONDS\n") - return 1 - - try: - seconds = float(process.args[0]) - if seconds < 0: - process.stderr.write("sleep: invalid time interval\n") - return 1 - - time.sleep(seconds) - return 0 - except ValueError: - process.stderr.write(f"sleep: invalid time interval '{process.args[0]}'\n") - return 1 - except KeyboardInterrupt: - # Re-raise KeyboardInterrupt to allow proper signal propagation - # This allows the script executor to handle Ctrl-C properly - raise diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/sort.py b/third_party/agfs/agfs-shell/agfs_shell/commands/sort.py deleted file mode 100644 index 7d476e184..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/sort.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -SORT command - sort lines of text. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('sort') -def cmd_sort(process: Process) -> int: - """ - Sort lines of text - - Usage: sort [-r] - """ - reverse = '-r' in process.args - - # Read lines from stdin - lines = process.stdin.readlines() - lines.sort(reverse=reverse) - - for line in lines: - process.stdout.write(line) - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/stat.py b/third_party/agfs/agfs-shell/agfs_shell/commands/stat.py deleted file mode 100644 index 1051862f5..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/stat.py +++ /dev/null @@ -1,74 +0,0 @@ -""" -STAT command - display file status. -""" - -from ..process import Process -from ..command_decorators import command -from ..utils.formatters import mode_to_rwx -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('stat') -def cmd_stat(process: Process) -> int: - """ - Display file status and check if file exists - - Usage: stat path - """ - if not process.args: - process.stderr.write("stat: missing operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("stat: filesystem not available\n") - return 1 - - path = process.args[0] - - try: - # Get file info from the filesystem - file_info = process.filesystem.get_file_info(path) - - # File exists, display information - name = file_info.get('name', path.split('/')[-1] if '/' in path else path) - is_dir = file_info.get('isDir', False) or file_info.get('type') == 'directory' - size = file_info.get('size', 0) - - # Get mode/permissions - mode_str = file_info.get('mode', '') - if mode_str and isinstance(mode_str, str) and len(mode_str) >= 9: - perms = mode_str[:9] - elif mode_str and isinstance(mode_str, int): - perms = mode_to_rwx(mode_str) - else: - perms = 'rwxr-xr-x' if is_dir else 'rw-r--r--' - - # Get modification time - mtime = file_info.get('modTime', file_info.get('mtime', '')) - if mtime: - if 'T' in mtime: - mtime = mtime.replace('T', ' ').replace('Z', '').split('.')[0] - elif len(mtime) > 19: - mtime = mtime[:19] - else: - mtime = 'unknown' - - # Build output - file_type = 'directory' if is_dir else 'regular file' - output = f" File: {name}\n" - output += f" Type: {file_type}\n" - output += f" Size: {size} bytes\n" - output += f" Mode: {perms}\n" - output += f" Modified: {mtime}\n" - - process.stdout.write(output.encode('utf-8')) - return 0 - - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write("stat: No such file or directory\n") - else: - process.stderr.write(f"stat: {path}: {error_msg}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/tail.py b/third_party/agfs/agfs-shell/agfs_shell/commands/tail.py deleted file mode 100644 index 7c7b4fdf7..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/tail.py +++ /dev/null @@ -1,179 +0,0 @@ -""" -TAIL command - output the last part of files. -""" - -import time -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True, supports_streaming=True) -@register_command('tail') -def cmd_tail(process: Process) -> int: - """ - Output the last part of files - - Usage: tail [-n count] [-f] [-F] [file...] - - Options: - -n count Output the last count lines (default: 10) - -f Follow mode: show last n lines, then continuously follow - -F Stream mode: for streamfs/streamrotatefs only - Continuously reads from the stream without loading history - Ideal for infinite streams like /streamfs/* or /streamrotate/* - """ - n = 10 # default - follow = False - stream_only = False # -F flag: skip reading history - files = [] - - # Parse flags - args = process.args[:] - i = 0 - while i < len(args): - if args[i] == '-n' and i + 1 < len(args): - try: - n = int(args[i + 1]) - i += 2 - continue - except ValueError: - process.stderr.write(f"tail: invalid number: {args[i + 1]}\n") - return 1 - elif args[i] == '-f': - follow = True - i += 1 - elif args[i] == '-F': - follow = True - stream_only = True - i += 1 - else: - # This is a file argument - files.append(args[i]) - i += 1 - - # Handle stdin or files - if not files: - # Read from stdin - lines = process.stdin.readlines() - for line in lines[-n:]: - process.stdout.write(line) - - if follow: - process.stderr.write(b"tail: warning: following stdin is not supported\n") - - return 0 - - # Read from files - if not follow: - # Normal tail mode - read last n lines from each file - for filename in files: - try: - if not process.filesystem: - process.stderr.write(b"tail: filesystem not available\n") - return 1 - - # Use streaming mode to read entire file - stream = process.filesystem.read_file(filename, stream=True) - chunks = [] - for chunk in stream: - if chunk: - chunks.append(chunk) - content = b''.join(chunks) - lines = content.decode('utf-8', errors='replace').splitlines(keepends=True) - for line in lines[-n:]: - process.stdout.write(line) - except Exception as e: - process.stderr.write(f"tail: {filename}: {str(e)}\n") - return 1 - else: - # Follow mode - continuously read new content - if len(files) > 1: - process.stderr.write(b"tail: warning: following multiple files not yet supported, using first file\n") - - filename = files[0] - - try: - if process.filesystem: - if stream_only: - # -F mode: Stream-only mode for filesystems that support streaming - # This mode uses continuous streaming read without loading history - process.stderr.write(b"==> Continuously reading from stream <==\n") - process.stdout.flush() - - # Use continuous streaming read - try: - stream = process.filesystem.read_file(filename, stream=True) - for chunk in stream: - if chunk: - process.stdout.write(chunk) - process.stdout.flush() - except KeyboardInterrupt: - # Re-raise to allow proper signal propagation in script mode - raise - except Exception as e: - error_msg = str(e) - # Check if it's a streaming-related error - if "stream mode" in error_msg.lower() or "use stream" in error_msg.lower(): - process.stderr.write(f"tail: {filename}: {error_msg}\n".encode()) - process.stderr.write(b" Note: -F requires a filesystem that supports streaming\n") - else: - process.stderr.write(f"tail: {filename}: {error_msg}\n".encode()) - return 1 - else: - # -f mode: Traditional follow mode - # First, output the last n lines - stream = process.filesystem.read_file(filename, stream=True) - chunks = [] - for chunk in stream: - if chunk: - chunks.append(chunk) - content = b''.join(chunks) - lines = content.decode('utf-8', errors='replace').splitlines(keepends=True) - for line in lines[-n:]: - process.stdout.write(line) - process.stdout.flush() - - # Get current file size - file_info = process.filesystem.get_file_info(filename) - current_size = file_info.get('size', 0) - - # Now continuously poll for new content - try: - while True: - time.sleep(0.1) # Poll every 100ms - - # Check file size - try: - file_info = process.filesystem.get_file_info(filename) - new_size = file_info.get('size', 0) - except Exception: - # File might not exist yet, keep waiting - continue - - if new_size > current_size: - # Read new content from offset using streaming - stream = process.filesystem.read_file( - filename, - offset=current_size, - size=new_size - current_size, - stream=True - ) - for chunk in stream: - if chunk: - process.stdout.write(chunk) - process.stdout.flush() - current_size = new_size - except KeyboardInterrupt: - # Re-raise to allow proper signal propagation in script mode - raise - else: - # No filesystem - should not happen in normal usage - process.stderr.write(b"tail: filesystem not available\n") - return 1 - - except Exception as e: - process.stderr.write(f"tail: {filename}: {str(e)}\n") - return 1 - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/tee.py b/third_party/agfs/agfs-shell/agfs_shell/commands/tee.py deleted file mode 100644 index 38e9d4c3d..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/tee.py +++ /dev/null @@ -1,69 +0,0 @@ -""" -TEE command - read from stdin and write to both stdout and files. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('tee') -def cmd_tee(process: Process) -> int: - """ - Read from stdin and write to both stdout and files (streaming mode) - - Usage: tee [-a] [file...] - - Options: - -a Append to files instead of overwriting - """ - append = False - files = [] - - # Parse arguments - for arg in process.args: - if arg == '-a': - append = True - else: - files.append(arg) - - if files and not process.filesystem: - process.stderr.write(b"tee: filesystem not available\n") - return 1 - - # Read input lines - lines = process.stdin.readlines() - - # Write to stdout (streaming: flush after each line) - for line in lines: - process.stdout.write(line) - process.stdout.flush() - - # Write to files - if files: - if append: - # Append mode: must collect all data - content = b''.join(lines) - for filename in files: - try: - process.filesystem.write_file(filename, content, append=True) - except Exception as e: - process.stderr.write(f"tee: {filename}: {str(e)}\n".encode()) - return 1 - else: - # Non-append mode: use streaming write via iterator - # Create an iterator from lines - def line_iterator(): - for line in lines: - yield line - - for filename in files: - try: - # Pass iterator to write_file for streaming - process.filesystem.write_file(filename, line_iterator(), append=False) - except Exception as e: - process.stderr.write(f"tee: {filename}: {str(e)}\n".encode()) - return 1 - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/test.py b/third_party/agfs/agfs-shell/agfs_shell/commands/test.py deleted file mode 100644 index 7254bbe3d..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/test.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -TEST command - evaluate conditional expressions. -""" - -from typing import List -from ..process import Process -from ..command_decorators import command -from . import register_command - - -def _evaluate_test_expression(args: List[str], process: Process) -> bool: - """Evaluate a test expression""" - if not args: - return False - - # Single argument - test if non-empty string - if len(args) == 1: - return bool(args[0]) - - # Negation operator - if args[0] == '!': - return not _evaluate_test_expression(args[1:], process) - - # File test operators - if args[0] == '-f': - if len(args) < 2: - raise ValueError("-f requires an argument") - path = args[1] - if process.filesystem: - try: - info = process.filesystem.get_file_info(path) - is_dir = info.get('isDir', False) or info.get('type') == 'directory' - return not is_dir - except: - return False - return False - - if args[0] == '-d': - if len(args) < 2: - raise ValueError("-d requires an argument") - path = args[1] - if process.filesystem: - return process.filesystem.is_directory(path) - return False - - if args[0] == '-e': - if len(args) < 2: - raise ValueError("-e requires an argument") - path = args[1] - if process.filesystem: - return process.filesystem.file_exists(path) - return False - - # String test operators - if args[0] == '-z': - if len(args) < 2: - raise ValueError("-z requires an argument") - return len(args[1]) == 0 - - if args[0] == '-n': - if len(args) < 2: - raise ValueError("-n requires an argument") - return len(args[1]) > 0 - - # Binary operators - if len(args) >= 3: - # Logical AND - if '-a' in args: - idx = args.index('-a') - left = _evaluate_test_expression(args[:idx], process) - right = _evaluate_test_expression(args[idx+1:], process) - return left and right - - # Logical OR - if '-o' in args: - idx = args.index('-o') - left = _evaluate_test_expression(args[:idx], process) - right = _evaluate_test_expression(args[idx+1:], process) - return left or right - - # String comparison - if args[1] == '=': - return args[0] == args[2] - - if args[1] == '!=': - return args[0] != args[2] - - # Integer comparison - if args[1] in ['-eq', '-ne', '-gt', '-lt', '-ge', '-le']: - try: - left = int(args[0]) - right = int(args[2]) - if args[1] == '-eq': - return left == right - elif args[1] == '-ne': - return left != right - elif args[1] == '-gt': - return left > right - elif args[1] == '-lt': - return left < right - elif args[1] == '-ge': - return left >= right - elif args[1] == '-le': - return left <= right - except ValueError: - raise ValueError(f"integer expression expected: {args[0]} or {args[2]}") - - # Default: non-empty first argument - return bool(args[0]) - - -@command() -@register_command('test', '[') -def cmd_test(process: Process) -> int: - """ - Evaluate conditional expressions (similar to bash test/[) - - Usage: test EXPRESSION - [ EXPRESSION ] - - File operators: - -f FILE True if file exists and is a regular file - -d FILE True if file exists and is a directory - -e FILE True if file exists - - String operators: - -z STRING True if string is empty - -n STRING True if string is not empty - STRING1 = STRING2 True if strings are equal - STRING1 != STRING2 True if strings are not equal - - Integer operators: - INT1 -eq INT2 True if integers are equal - INT1 -ne INT2 True if integers are not equal - INT1 -gt INT2 True if INT1 is greater than INT2 - INT1 -lt INT2 True if INT1 is less than INT2 - INT1 -ge INT2 True if INT1 is greater than or equal to INT2 - INT1 -le INT2 True if INT1 is less than or equal to INT2 - - Logical operators: - ! EXPR True if expr is false - EXPR -a EXPR True if both expressions are true (AND) - EXPR -o EXPR True if either expression is true (OR) - """ - # Handle [ command - last arg should be ] - if process.command == '[': - if not process.args or process.args[-1] != ']': - process.stderr.write("[: missing ']'\n") - return 2 - # Remove the closing ] - process.args = process.args[:-1] - - if not process.args: - # Empty test is false - return 1 - - # Evaluate the expression - try: - result = _evaluate_test_expression(process.args, process) - return 0 if result else 1 - except Exception as e: - process.stderr.write(f"test: {e}\n") - return 2 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/touch.py b/third_party/agfs/agfs-shell/agfs_shell/commands/touch.py deleted file mode 100644 index 6d431c96a..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/touch.py +++ /dev/null @@ -1,34 +0,0 @@ -""" -TOUCH command - touch file (update timestamp). -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command(needs_path_resolution=True) -@register_command('touch') -def cmd_touch(process: Process) -> int: - """ - Touch file (update timestamp) - - Usage: touch file... - """ - if not process.args: - process.stderr.write("touch: missing file operand\n") - return 1 - - if not process.filesystem: - process.stderr.write("touch: filesystem not available\n") - return 1 - - for path in process.args: - try: - process.filesystem.touch_file(path) - except Exception as e: - error_msg = str(e) - process.stderr.write(f"touch: {path}: {error_msg}\n") - return 1 - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/tr.py b/third_party/agfs/agfs-shell/agfs_shell/commands/tr.py deleted file mode 100644 index 48efab1d9..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/tr.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -TR command - translate characters. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('tr') -def cmd_tr(process: Process) -> int: - """ - Translate characters - - Usage: tr set1 set2 - """ - if len(process.args) < 2: - process.stderr.write("tr: missing operand\n") - return 1 - - set1 = process.args[0].encode('utf-8') - set2 = process.args[1].encode('utf-8') - - if len(set1) != len(set2): - process.stderr.write("tr: sets must be same length\n") - return 1 - - # Create translation table - trans = bytes.maketrans(set1, set2) - - # Read and translate - data = process.stdin.read() - translated = data.translate(trans) - process.stdout.write(translated) - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/tree.py b/third_party/agfs/agfs-shell/agfs_shell/commands/tree.py deleted file mode 100644 index 75f3b283d..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/tree.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -TREE command - (auto-migrated from builtins.py) -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -def _print_tree(process, path, prefix, is_last, max_depth, current_depth, dirs_only, show_hidden, stats): - """ - Recursively print directory tree - - Args: - process: Process object - path: Current directory path - prefix: Prefix string for tree drawing - is_last: Whether this is the last item in the parent directory - max_depth: Maximum depth to traverse (None for unlimited) - current_depth: Current depth level - dirs_only: Only show directories - show_hidden: Show hidden files - stats: Dictionary to track file/dir counts - """ - # Check depth limit - if max_depth is not None and current_depth >= max_depth: - return - - try: - # List directory contents - entries = process.filesystem.list_directory(path) - - # Filter entries - filtered_entries = [] - for entry in entries: - name = entry.get('name', '') - - # Skip hidden files unless show_hidden is True - if not show_hidden and name.startswith('.'): - continue - - is_dir = entry.get('isDir', False) or entry.get('type') == 'directory' - - # Skip files if dirs_only is True - if dirs_only and not is_dir: - continue - - filtered_entries.append(entry) - - # Sort entries: directories first, then by name - filtered_entries.sort(key=lambda e: (not (e.get('isDir', False) or e.get('type') == 'directory'), e.get('name', ''))) - - # Process each entry - for idx, entry in enumerate(filtered_entries): - name = entry.get('name', '') - is_dir = entry.get('isDir', False) or entry.get('type') == 'directory' - is_last_entry = (idx == len(filtered_entries) - 1) - - # Update statistics - if is_dir: - stats['dirs'] += 1 - else: - stats['files'] += 1 - - # Determine the tree characters to use - if is_last_entry: - connector = "└── " - extension = " " - else: - connector = "├── " - extension = "│ " - - # Format name with color - if is_dir: - # Blue color for directories - display_name = f"\033[1;34m{name}/\033[0m" - else: - display_name = name - - # Print the entry - line = f"{prefix}{connector}{display_name}\n" - process.stdout.write(line.encode('utf-8')) - - # Recursively process subdirectories - if is_dir: - subdir_path = os.path.join(path, name) - subdir_path = os.path.normpath(subdir_path) - new_prefix = prefix + extension - - _print_tree( - process, - subdir_path, - new_prefix, - is_last_entry, - max_depth, - current_depth + 1, - dirs_only, - show_hidden, - stats - ) - - except Exception as e: - # If we can't read a directory, print an error but continue - error_msg = str(e) - if "Permission denied" in error_msg: - error_line = f"{prefix}[error opening dir]\n" - else: - error_line = f"{prefix}[error: {error_msg}]\n" - process.stdout.write(error_line.encode('utf-8')) - - - -@command(needs_path_resolution=True, supports_streaming=True) -@register_command('tree') -def cmd_tree(process: Process) -> int: - """ - List contents of directories in a tree-like format - - Usage: tree [OPTIONS] [path] - - Options: - -L level Descend only level directories deep - -d List directories only - -a Show all files (including hidden files starting with .) - --noreport Don't print file and directory count at the end - - Examples: - tree # Show tree of current directory - tree /path/to/dir # Show tree of specific directory - tree -L 2 # Show tree with max depth of 2 - tree -d # Show only directories - tree -a # Show all files including hidden ones - """ - # Parse arguments - max_depth = None - dirs_only = False - show_hidden = False - show_report = True - path = None - - args = process.args[:] - i = 0 - while i < len(args): - if args[i] == '-L' and i + 1 < len(args): - try: - max_depth = int(args[i + 1]) - if max_depth < 0: - process.stderr.write("tree: invalid level, must be >= 0\n") - return 1 - i += 2 - continue - except ValueError: - process.stderr.write(f"tree: invalid level '{args[i + 1]}'\n") - return 1 - elif args[i] == '-d': - dirs_only = True - i += 1 - elif args[i] == '-a': - show_hidden = True - i += 1 - elif args[i] == '--noreport': - show_report = False - i += 1 - elif args[i].startswith('-'): - # Handle combined flags - if args[i] == '-L': - process.stderr.write("tree: option requires an argument -- 'L'\n") - return 1 - # Unknown option - process.stderr.write(f"tree: invalid option -- '{args[i]}'\n") - return 1 - else: - # This is the path argument - if path is not None: - process.stderr.write("tree: too many arguments\n") - return 1 - path = args[i] - i += 1 - - # Default to current working directory - if path is None: - path = getattr(process, 'cwd', '/') - - if not process.filesystem: - process.stderr.write("tree: filesystem not available\n") - return 1 - - # Check if path exists - try: - info = process.filesystem.get_file_info(path) - is_dir = info.get('isDir', False) or info.get('type') == 'directory' - - if not is_dir: - process.stderr.write(f"tree: {path}: Not a directory\n") - return 1 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - process.stderr.write(f"tree: {path}: No such file or directory\n") - else: - process.stderr.write(f"tree: {path}: {error_msg}\n") - return 1 - - # Print the root path - process.stdout.write(f"{path}\n".encode('utf-8')) - - # Track statistics - stats = {'dirs': 0, 'files': 0} - - # Build and print the tree - try: - _print_tree(process, path, "", True, max_depth, 0, dirs_only, show_hidden, stats) - except Exception as e: - process.stderr.write(f"tree: error traversing {path}: {e}\n") - return 1 - - # Print report - if show_report: - if dirs_only: - report = f"\n{stats['dirs']} directories\n" - else: - report = f"\n{stats['dirs']} directories, {stats['files']} files\n" - process.stdout.write(report.encode('utf-8')) - - return 0 - - -def _print_tree(process, path, prefix, is_last, max_depth, current_depth, dirs_only, show_hidden, stats): - """ - Recursively print directory tree - - Args: - process: Process object - path: Current directory path - prefix: Prefix string for tree drawing - is_last: Whether this is the last item in the parent directory - max_depth: Maximum depth to traverse (None for unlimited) - current_depth: Current depth level - dirs_only: Only show directories - show_hidden: Show hidden files - stats: Dictionary to track file/dir counts - """ - # Check depth limit - if max_depth is not None and current_depth >= max_depth: - return - - try: - # List directory contents - entries = process.filesystem.list_directory(path) - - # Filter entries - filtered_entries = [] - for entry in entries: - name = entry.get('name', '') - - # Skip hidden files unless show_hidden is True - if not show_hidden and name.startswith('.'): - continue - - is_dir = entry.get('isDir', False) or entry.get('type') == 'directory' - - # Skip files if dirs_only is True - if dirs_only and not is_dir: - continue - - filtered_entries.append(entry) - - # Sort entries: directories first, then by name - filtered_entries.sort(key=lambda e: (not (e.get('isDir', False) or e.get('type') == 'directory'), e.get('name', ''))) - - # Process each entry - for idx, entry in enumerate(filtered_entries): - name = entry.get('name', '') - is_dir = entry.get('isDir', False) or entry.get('type') == 'directory' - is_last_entry = (idx == len(filtered_entries) - 1) - - # Update statistics - if is_dir: - stats['dirs'] += 1 - else: - stats['files'] += 1 - - # Determine the tree characters to use - if is_last_entry: - connector = "└── " - extension = " " - else: - connector = "├── " - extension = "│ " - - # Format name with color - if is_dir: - # Blue color for directories - display_name = f"\033[1;34m{name}/\033[0m" - else: - display_name = name - - # Print the entry - line = f"{prefix}{connector}{display_name}\n" - process.stdout.write(line.encode('utf-8')) - - # Recursively process subdirectories - if is_dir: - subdir_path = os.path.join(path, name) - subdir_path = os.path.normpath(subdir_path) - new_prefix = prefix + extension - - _print_tree( - process, - subdir_path, - new_prefix, - is_last_entry, - max_depth, - current_depth + 1, - dirs_only, - show_hidden, - stats - ) - - except Exception as e: - # If we can't read a directory, print an error but continue - error_msg = str(e) - if "Permission denied" in error_msg: - error_line = f"{prefix}[error opening dir]\n" - else: - error_line = f"{prefix}[error: {error_msg}]\n" - process.stdout.write(error_line.encode('utf-8')) diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/true.py b/third_party/agfs/agfs-shell/agfs_shell/commands/true.py deleted file mode 100644 index 89a04f32a..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/true.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -TRUE command - return success. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('true') -def cmd_true(process: Process) -> int: - """ - Return success (exit code 0) - - Usage: true - - Always returns 0 (success). Useful in scripts and conditionals. - """ - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/uniq.py b/third_party/agfs/agfs-shell/agfs_shell/commands/uniq.py deleted file mode 100644 index 041b0dd29..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/uniq.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -UNIQ command - report or omit repeated lines. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('uniq') -def cmd_uniq(process: Process) -> int: - """ - Report or omit repeated lines - - Usage: uniq - """ - lines = process.stdin.readlines() - if not lines: - return 0 - - prev_line = lines[0] - process.stdout.write(prev_line) - - for line in lines[1:]: - if line != prev_line: - process.stdout.write(line) - prev_line = line - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/unset.py b/third_party/agfs/agfs-shell/agfs_shell/commands/unset.py deleted file mode 100644 index a34c04b39..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/unset.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -UNSET command - unset environment variables. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('unset') -def cmd_unset(process: Process) -> int: - """ - Unset environment variables - - Usage: unset VAR [VAR ...] - """ - if not process.args: - process.stderr.write("unset: missing variable name\n") - return 1 - - if not hasattr(process, 'env'): - return 0 - - for var_name in process.args: - if var_name in process.env: - del process.env[var_name] - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/upload.py b/third_party/agfs/agfs-shell/agfs_shell/commands/upload.py deleted file mode 100644 index 81e8c4b22..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/upload.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -UPLOAD command - (auto-migrated from builtins.py) -""" - -import os - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('upload') -def cmd_upload(process: Process) -> int: - """ - Upload a local file or directory to AGFS - - Usage: upload [-r] - """ - # Parse arguments - recursive = False - args = process.args[:] - - if args and args[0] == '-r': - recursive = True - args = args[1:] - - if len(args) != 2: - process.stderr.write("upload: usage: upload [-r] \n") - return 1 - - local_path = args[0] - agfs_path = args[1] - - # Resolve agfs_path relative to current working directory - if not agfs_path.startswith('/'): - agfs_path = os.path.join(process.cwd, agfs_path) - agfs_path = os.path.normpath(agfs_path) - - try: - # Check if local path exists - if not os.path.exists(local_path): - process.stderr.write(f"upload: {local_path}: No such file or directory\n") - return 1 - - # Check if destination is a directory - try: - dest_info = process.filesystem.get_file_info(agfs_path) - if dest_info.get('isDir', False): - # Destination is a directory, append source filename - source_basename = os.path.basename(local_path) - agfs_path = os.path.join(agfs_path, source_basename) - agfs_path = os.path.normpath(agfs_path) - except Exception: - # Destination doesn't exist, use as-is - pass - - if os.path.isfile(local_path): - # Upload single file - return _upload_file(process, local_path, agfs_path) - elif os.path.isdir(local_path): - if not recursive: - process.stderr.write(f"upload: {local_path}: Is a directory (use -r to upload recursively)\n") - return 1 - # Upload directory recursively - return _upload_dir(process, local_path, agfs_path) - else: - process.stderr.write(f"upload: {local_path}: Not a file or directory\n") - return 1 - - except Exception as e: - error_msg = str(e) - process.stderr.write(f"upload: {error_msg}\n") - return 1 - - -def _upload_file(process: Process, local_path: str, agfs_path: str, show_progress: bool = True) -> int: - """Helper: Upload a single file to AGFS""" - try: - with open(local_path, 'rb') as f: - data = f.read() - process.filesystem.write_file(agfs_path, data, append=False) - - if show_progress: - process.stdout.write(f"Uploaded {len(data)} bytes to {agfs_path}\n") - process.stdout.flush() - return 0 - - except Exception as e: - process.stderr.write(f"upload: {local_path}: {str(e)}\n") - return 1 - - -def _upload_dir(process: Process, local_path: str, agfs_path: str) -> int: - """Helper: Upload a directory recursively to AGFS""" - import stat as stat_module - - try: - # Create target directory in AGFS if it doesn't exist - try: - info = process.filesystem.get_file_info(agfs_path) - if not info.get('isDir', False): - process.stderr.write(f"upload: {agfs_path}: Not a directory\n") - return 1 - except Exception: - # Directory doesn't exist, create it - try: - # Use mkdir command to create directory - from pyagfs import AGFSClient - process.filesystem.client.mkdir(agfs_path) - except Exception as e: - process.stderr.write(f"upload: cannot create directory {agfs_path}: {str(e)}\n") - return 1 - - # Walk through local directory - for root, dirs, files in os.walk(local_path): - # Calculate relative path - rel_path = os.path.relpath(root, local_path) - if rel_path == '.': - current_agfs_dir = agfs_path - else: - current_agfs_dir = os.path.join(agfs_path, rel_path) - current_agfs_dir = os.path.normpath(current_agfs_dir) - - # Create subdirectories in AGFS - for dirname in dirs: - dir_agfs_path = os.path.join(current_agfs_dir, dirname) - dir_agfs_path = os.path.normpath(dir_agfs_path) - try: - process.filesystem.client.mkdir(dir_agfs_path) - except Exception: - # Directory might already exist, ignore - pass - - # Upload files - for filename in files: - local_file = os.path.join(root, filename) - agfs_file = os.path.join(current_agfs_dir, filename) - agfs_file = os.path.normpath(agfs_file) - - result = _upload_file(process, local_file, agfs_file) - if result != 0: - return result - - return 0 - - except Exception as e: - process.stderr.write(f"upload: {str(e)}\n") - return 1 diff --git a/third_party/agfs/agfs-shell/agfs_shell/commands/wc.py b/third_party/agfs/agfs-shell/agfs_shell/commands/wc.py deleted file mode 100644 index 12f820858..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/commands/wc.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -WC command - count lines, words, and bytes. -""" - -from ..process import Process -from ..command_decorators import command -from . import register_command - - -@command() -@register_command('wc') -def cmd_wc(process: Process) -> int: - """ - Count lines, words, and bytes - - Usage: wc [-l] [-w] [-c] - """ - count_lines = False - count_words = False - count_bytes = False - - # Parse flags - flags = [arg for arg in process.args if arg.startswith('-')] - if not flags: - # Default: count all - count_lines = count_words = count_bytes = True - else: - for flag in flags: - if 'l' in flag: - count_lines = True - if 'w' in flag: - count_words = True - if 'c' in flag: - count_bytes = True - - # Read all data from stdin - data = process.stdin.read() - - lines = data.count(b'\n') - words = len(data.split()) - bytes_count = len(data) - - result = [] - if count_lines: - result.append(str(lines)) - if count_words: - result.append(str(words)) - if count_bytes: - result.append(str(bytes_count)) - - output = ' '.join(result) + '\n' - process.stdout.write(output) - - return 0 diff --git a/third_party/agfs/agfs-shell/agfs_shell/completer.py b/third_party/agfs/agfs-shell/agfs_shell/completer.py deleted file mode 100644 index ce5548f50..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/completer.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Tab completion support for agfs-shell""" - -import os -import shlex -from typing import List, Optional -from .builtins import BUILTINS -from .filesystem import AGFSFileSystem - - -class ShellCompleter: - """Tab completion for shell commands and AGFS paths""" - - def __init__(self, filesystem: AGFSFileSystem): - self.filesystem = filesystem - self.command_names = sorted(BUILTINS.keys()) - self.matches = [] - self.shell = None # Will be set by shell to access cwd - - def complete(self, text: str, state: int) -> Optional[str]: - """ - Readline completion function - - Args: - text: The text to complete - state: The completion state (0 for first call, increments for each match) - - Returns: - The next completion match, or None when no more matches - """ - if state == 0: - # First call - generate new matches - import readline - line = readline.get_line_buffer() - begin_idx = readline.get_begidx() - end_idx = readline.get_endidx() - - # Determine if we're completing a command or a path - if begin_idx == 0 or line[:begin_idx].strip() == '': - # Beginning of line - complete command names - self.matches = self._complete_command(text) - else: - # Middle of line - complete paths - self.matches = self._complete_path(text) - - # Return the next match - if state < len(self.matches): - return self.matches[state] - return None - - def _complete_command(self, text: str) -> List[str]: - """Complete command names""" - if not text: - return self.command_names - - matches = [cmd for cmd in self.command_names if cmd.startswith(text)] - return matches - - def _needs_quoting(self, path: str) -> bool: - """Check if a path needs to be quoted""" - # Characters that require quoting in shell - special_chars = ' \t\n|&;<>()$`\\"\'' - return any(c in path for c in special_chars) - - def _quote_if_needed(self, path: str) -> str: - """Quote a path if it contains spaces or special characters""" - if self._needs_quoting(path): - # Use shlex.quote for proper shell quoting - return shlex.quote(path) - return path - - def _complete_path(self, text: str) -> List[str]: - """Complete AGFS paths""" - # Get current working directory - cwd = self.shell.cwd if self.shell else '/' - - # Track if the text starts with a quote - quote_char = None - if text and text[0] in ('"', "'"): - quote_char = text[0] - text = text[1:] # Remove the leading quote for path matching - - # Handle empty text - list current directory - if not text: - text = '.' - - # Resolve relative paths - if text.startswith('/'): - # Absolute path - full_text = text - else: - # Relative path - resolve against cwd - full_text = os.path.join(cwd, text) - full_text = os.path.normpath(full_text) - - # Split path into directory and partial filename - if full_text.endswith('/'): - # Directory path - list contents - directory = full_text - partial = '' - else: - # Partial path - split into dir and filename - directory = os.path.dirname(full_text) - partial = os.path.basename(full_text) - - # Handle current directory - if not directory or directory == '.': - directory = cwd - elif not directory.startswith('/'): - directory = os.path.join(cwd, directory) - directory = os.path.normpath(directory) - - # Get directory listing from AGFS - try: - entries = self.filesystem.list_directory(directory) - - # Determine if we should return relative or absolute paths - return_relative = not text.startswith('/') - - # Filter by partial match and construct paths - matches = [] - for entry in entries: - name = entry.get('name', '') - if name and name.startswith(partial): - # Construct absolute path - if directory == '/': - abs_path = f"/{name}" - else: - # Remove trailing slash from directory before joining - dir_clean = directory.rstrip('/') - abs_path = f"{dir_clean}/{name}" - - # Add trailing slash for directories - if entry.get('type') == 'directory': - abs_path += '/' - - # Convert to relative path if needed - final_path = None - if return_relative and cwd != '/': - # Make path relative to cwd - if abs_path.startswith(cwd + '/'): - final_path = abs_path[len(cwd) + 1:] - elif abs_path == cwd: - final_path = '.' - else: - # Path not under cwd, use absolute - final_path = abs_path - else: - final_path = abs_path - - # Quote the path if needed - if quote_char: - # User started with a quote, so add matching quote - # Don't use shlex.quote as user already provided quote - final_path = f"{quote_char}{final_path}{quote_char}" - else: - # Auto-quote if the path needs it - final_path = self._quote_if_needed(final_path) - - matches.append(final_path) - - return sorted(matches) - except Exception: - # If directory listing fails, return no matches - return [] diff --git a/third_party/agfs/agfs-shell/agfs_shell/config.py b/third_party/agfs/agfs-shell/agfs_shell/config.py deleted file mode 100644 index 200a51c23..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/config.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Configuration management for agfs-shell""" - -import os - - -class Config: - """Configuration for AGFS shell""" - - def __init__(self): - # Default AGFS server URL - # Support both AGFS_API_URL (preferred) and AGFS_SERVER_URL (backward compatibility) - self.server_url = os.getenv('AGFS_API_URL') or os.getenv('AGFS_SERVER_URL', 'http://localhost:8080') - - # Request timeout in seconds (default: 30) - # Can be overridden via AGFS_TIMEOUT environment variable - # Increased default for better support of large file transfers - timeout_str = os.getenv('AGFS_TIMEOUT', '30') - try: - self.timeout = int(timeout_str) - except ValueError: - self.timeout = 30 - - @classmethod - def from_env(cls): - """Create configuration from environment variables""" - return cls() - - @classmethod - def from_args(cls, server_url: str = None, timeout: int = None): - """Create configuration from command line arguments""" - config = cls() - if server_url: - config.server_url = server_url - if timeout is not None: - config.timeout = timeout - return config - - def __repr__(self): - return f"Config(server_url={self.server_url}, timeout={self.timeout})" diff --git a/third_party/agfs/agfs-shell/agfs_shell/control_flow.py b/third_party/agfs/agfs-shell/agfs_shell/control_flow.py deleted file mode 100644 index e1ee55c8b..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/control_flow.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -Control flow exceptions for shell execution. - -Using exceptions instead of exit codes for control flow provides: -1. Clean propagation through nested structures -2. Support for break N / continue N -3. Type safety and clear semantics -4. No confusion with actual command exit codes -""" - - -class ControlFlowException(Exception): - """Base class for control flow exceptions""" - pass - - -class BreakException(ControlFlowException): - """ - Raised by 'break' command to exit loops. - - Attributes: - levels: Number of loop levels to break out of (default 1) - Decremented as it propagates through each loop level. - - Examples: - break -> BreakException(levels=1) # exit innermost loop - break 2 -> BreakException(levels=2) # exit two levels of loops - """ - - def __init__(self, levels: int = 1): - super().__init__(f"break {levels}") - self.levels = max(1, levels) # At least 1 level - - def __repr__(self): - return f"BreakException(levels={self.levels})" - - -class ContinueException(ControlFlowException): - """ - Raised by 'continue' command to skip to next iteration. - - Attributes: - levels: Number of loop levels to skip (default 1) - If levels > 1, continue affects an outer loop. - - Examples: - continue -> ContinueException(levels=1) # continue innermost loop - continue 2 -> ContinueException(levels=2) # continue outer loop - """ - - def __init__(self, levels: int = 1): - super().__init__(f"continue {levels}") - self.levels = max(1, levels) - - def __repr__(self): - return f"ContinueException(levels={self.levels})" - - -class ReturnException(ControlFlowException): - """ - Raised by 'return' command to exit functions. - - Attributes: - exit_code: Return value (exit code) for the function - - Examples: - return -> ReturnException(exit_code=0) - return 1 -> ReturnException(exit_code=1) - """ - - def __init__(self, exit_code: int = 0): - super().__init__(f"return {exit_code}") - self.exit_code = exit_code - - def __repr__(self): - return f"ReturnException(exit_code={self.exit_code})" diff --git a/third_party/agfs/agfs-shell/agfs_shell/control_parser.py b/third_party/agfs/agfs-shell/agfs_shell/control_parser.py deleted file mode 100644 index aff965450..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/control_parser.py +++ /dev/null @@ -1,535 +0,0 @@ -""" -Parser for shell control flow structures. - -This module handles parsing of: -- for/while/until loops -- if/elif/else statements -- function definitions - -The parser converts text lines into AST nodes defined in ast_nodes.py. -""" - -from typing import List, Optional, Tuple -from .ast_nodes import ( - Statement, CommandStatement, - ForStatement, WhileStatement, UntilStatement, - IfStatement, IfBranch, FunctionDefinition -) -from .lexer import strip_comments -import re - - -class ParseError(Exception): - """Raised when parsing fails""" - def __init__(self, message: str, line_number: Optional[int] = None): - self.line_number = line_number - super().__init__(f"Parse error{f' at line {line_number}' if line_number else ''}: {message}") - - -class ControlParser: - """ - Parser for shell control flow structures. - - This parser handles multi-line constructs and produces AST nodes. - """ - - def __init__(self, shell=None): - """ - Initialize parser. - - Args: - shell: Shell instance (optional, for access to _strip_comment method) - """ - self.shell = shell - - def _strip_comment(self, line: str) -> str: - """Strip comments from a line, respecting quotes""" - return strip_comments(line) - - # ======================================================================== - # Main Parse Entry Points - # ======================================================================== - - def parse_for_loop(self, lines: List[str]) -> Optional[ForStatement]: - """ - Parse a for loop from lines. - - Syntax: - for VAR in ITEMS; do - COMMANDS - done - - Args: - lines: Lines comprising the for loop - - Returns: - ForStatement AST node or None on error - """ - state = 'for' - var_name = None - items_raw = "" - commands = [] - - i = 0 - while i < len(lines): - line = lines[i].strip() - i += 1 - - if not line or line.startswith('#'): - continue - - line_no_comment = self._strip_comment(line).strip() - - if line_no_comment == 'done': - break - elif line_no_comment == 'do': - state = 'do' - elif line_no_comment.startswith('do '): - state = 'do' - cmd = line_no_comment[3:].strip() - if cmd and cmd != 'done': - commands.append(cmd) - elif line_no_comment.startswith('for ') and var_name is None: - # Parse: for var in item1 item2 ... - parts = line_no_comment[4:].strip() - - # Handle trailing '; do' - if parts.endswith('; do'): - parts = parts[:-4].strip() - state = 'do' - elif parts.endswith(' do'): - parts = parts[:-3].strip() - state = 'do' - - # Split by 'in' - if ' in ' in parts: - var_part, items_part = parts.split(' in ', 1) - var_name = var_part.strip() - items_raw = self._strip_comment(items_part).strip() - else: - return None # Invalid syntax - else: - if state == 'do': - commands.append(line) - - if not var_name: - return None - - # Parse commands into statements - body = self._parse_block(commands) - - return ForStatement( - variable=var_name, - items_raw=items_raw, - body=body - ) - - def parse_while_loop(self, lines: List[str]) -> Optional[WhileStatement]: - """ - Parse a while loop from lines. - - Syntax: - while CONDITION; do - COMMANDS - done - """ - state = 'while' - condition = None - commands = [] - - i = 0 - while i < len(lines): - line = lines[i].strip() - i += 1 - - if not line or line.startswith('#'): - continue - - line_no_comment = self._strip_comment(line).strip() - - if line_no_comment == 'done': - break - elif line_no_comment == 'do': - state = 'do' - elif line_no_comment.startswith('do '): - state = 'do' - cmd = line_no_comment[3:].strip() - if cmd and cmd != 'done': - commands.append(cmd) - elif line_no_comment.startswith('while ') and condition is None: - cond = line_no_comment[6:].strip() - - if cond.endswith('; do'): - cond = cond[:-4].strip() - state = 'do' - elif cond.endswith(' do'): - cond = cond[:-3].strip() - state = 'do' - - condition = self._strip_comment(cond) - else: - if state == 'do': - commands.append(line) - - if not condition: - return None - - body = self._parse_block(commands) - - return WhileStatement( - condition=condition, - body=body - ) - - def parse_until_loop(self, lines: List[str]) -> Optional[UntilStatement]: - """ - Parse an until loop from lines. - - Syntax: - until CONDITION; do - COMMANDS - done - """ - state = 'until' - condition = None - commands = [] - - i = 0 - while i < len(lines): - line = lines[i].strip() - i += 1 - - if not line or line.startswith('#'): - continue - - line_no_comment = self._strip_comment(line).strip() - - if line_no_comment == 'done': - break - elif line_no_comment == 'do': - state = 'do' - elif line_no_comment.startswith('do '): - state = 'do' - cmd = line_no_comment[3:].strip() - if cmd and cmd != 'done': - commands.append(cmd) - elif line_no_comment.startswith('until ') and condition is None: - cond = line_no_comment[6:].strip() - - if cond.endswith('; do'): - cond = cond[:-4].strip() - state = 'do' - elif cond.endswith(' do'): - cond = cond[:-3].strip() - state = 'do' - - condition = self._strip_comment(cond) - else: - if state == 'do': - commands.append(line) - - if not condition: - return None - - body = self._parse_block(commands) - - return UntilStatement( - condition=condition, - body=body - ) - - def parse_if_statement(self, lines: List[str]) -> Optional[IfStatement]: - """ - Parse an if statement from lines. - - Syntax: - if CONDITION; then - COMMANDS - [elif CONDITION; then - COMMANDS]* - [else - COMMANDS] - fi - """ - branches = [] - current_condition = None - current_commands = [] - state = 'start' # start, condition, then, else - - for line in lines: - line_stripped = line.strip() - - if not line_stripped or line_stripped.startswith('#'): - continue - - line_no_comment = self._strip_comment(line_stripped).strip() - - if line_no_comment == 'fi': - # Save last branch - if state == 'then' and current_condition is not None: - branches.append(IfBranch( - condition=current_condition, - body=self._parse_block(current_commands) - )) - elif state == 'else': - # else_commands already in current_commands - pass - break - - elif line_no_comment == 'then': - state = 'then' - current_commands = [] - - elif line_no_comment.startswith('then '): - state = 'then' - current_commands = [] - cmd = line_no_comment[5:].strip() - if cmd and cmd != 'fi': - current_commands.append(cmd) - - elif line_no_comment.startswith('elif '): - # Save previous branch - if current_condition is not None: - branches.append(IfBranch( - condition=current_condition, - body=self._parse_block(current_commands) - )) - - # Parse elif condition - cond = line_no_comment[5:].strip() - cond = self._strip_comment(cond) - if cond.endswith('; then'): - cond = cond[:-6].strip() - state = 'then' - current_commands = [] - elif cond.endswith(' then'): - cond = cond[:-5].strip() - state = 'then' - current_commands = [] - else: - state = 'condition' - current_condition = cond.rstrip(';') - - elif line_no_comment == 'else': - # Save previous branch - if current_condition is not None: - branches.append(IfBranch( - condition=current_condition, - body=self._parse_block(current_commands) - )) - state = 'else' - current_condition = None - current_commands = [] - - elif line_no_comment.startswith('else '): - # Save previous branch - if current_condition is not None: - branches.append(IfBranch( - condition=current_condition, - body=self._parse_block(current_commands) - )) - state = 'else' - current_condition = None - current_commands = [] - cmd = line_no_comment[5:].strip() - if cmd and cmd != 'fi': - current_commands.append(cmd) - - elif line_no_comment.startswith('if ') and state == 'start': - cond = line_no_comment[3:].strip() - cond = self._strip_comment(cond) - if cond.endswith('; then'): - cond = cond[:-6].strip() - state = 'then' - current_commands = [] - elif cond.endswith(' then'): - cond = cond[:-5].strip() - state = 'then' - current_commands = [] - else: - state = 'condition' - current_condition = cond.rstrip(';') - - else: - if state in ('then', 'else'): - current_commands.append(line_stripped) - - if not branches and current_condition is None: - return None - - # Handle else block - else_body = None - if state == 'else' and current_commands: - else_body = self._parse_block(current_commands) - - return IfStatement( - branches=branches, - else_body=else_body - ) - - def parse_function_definition(self, lines: List[str]) -> Optional[FunctionDefinition]: - """Parse a function definition from lines""" - if not lines: - return None - - first_line = lines[0].strip() - - # Try single-line function: name() { cmd; } - match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{(.+)\}$', first_line) - if not match: - match = re.match(r'^function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{(.+)\}$', first_line) - - if match: - name = match.group(1) - body_str = match.group(2).strip() - commands = [cmd.strip() for cmd in body_str.split(';') if cmd.strip()] - return FunctionDefinition( - name=name, - body=self._parse_block(commands) - ) - - # Multi-line function: name() { \n ... \n } - match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{?\s*$', first_line) - if not match: - match = re.match(r'^function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?\s*$', first_line) - - if not match: - return None - - name = match.group(1) - commands = [] - - # Collect body - start = 1 - if not first_line.rstrip().endswith('{') and start < len(lines) and lines[start].strip() == '{': - start += 1 - - brace_depth = 1 - for i in range(start, len(lines)): - line = lines[i].strip() - if not line or line.startswith('#'): - continue - if line == '}': - brace_depth -= 1 - if brace_depth == 0: - break - elif '{' in line: - brace_depth += line.count('{') - line.count('}') - commands.append(lines[i]) - - return FunctionDefinition( - name=name, - body=self._parse_block(commands) - ) - - # ======================================================================== - # Block Parsing - Unified nested structure handling - # ======================================================================== - - def _parse_block(self, commands: List[str]) -> List[Statement]: - """ - Parse a list of command strings into a list of Statements. - - This handles nested structures by detecting keywords and - collecting the appropriate lines. - """ - statements = [] - i = 0 - - while i < len(commands): - cmd = commands[i].strip() - cmd_no_comment = self._strip_comment(cmd).strip() - - if not cmd or cmd.startswith('#'): - i += 1 - continue - - # Check for nested for loop - if cmd_no_comment.startswith('for '): - nested_lines, end_idx = self._collect_block(commands, i, 'for', 'done') - stmt = self.parse_for_loop(nested_lines) - if stmt: - statements.append(stmt) - i = end_idx + 1 - - # Check for nested while loop - elif cmd_no_comment.startswith('while '): - nested_lines, end_idx = self._collect_block(commands, i, 'while', 'done') - stmt = self.parse_while_loop(nested_lines) - if stmt: - statements.append(stmt) - i = end_idx + 1 - - # Check for nested until loop - elif cmd_no_comment.startswith('until '): - nested_lines, end_idx = self._collect_block(commands, i, 'until', 'done') - stmt = self.parse_until_loop(nested_lines) - if stmt: - statements.append(stmt) - i = end_idx + 1 - - # Check for nested if statement - elif cmd_no_comment.startswith('if '): - nested_lines, end_idx = self._collect_block_if(commands, i) - stmt = self.parse_if_statement(nested_lines) - if stmt: - statements.append(stmt) - i = end_idx + 1 - - # Regular command - else: - statements.append(CommandStatement(command=cmd)) - i += 1 - - return statements - - def _collect_block(self, commands: List[str], start: int, - start_keyword: str, end_keyword: str) -> Tuple[List[str], int]: - """ - Collect lines for a block structure (for/while/until ... done). - - Returns (collected_lines, end_index) - """ - lines = [commands[start]] - depth = 1 - i = start + 1 - - while i < len(commands): - line = commands[i] - line_no_comment = self._strip_comment(line).strip() - lines.append(line) - - if line_no_comment.startswith(f'{start_keyword} '): - depth += 1 - elif line_no_comment == end_keyword: - depth -= 1 - if depth == 0: - break - i += 1 - - return lines, i - - def _collect_block_if(self, commands: List[str], start: int) -> Tuple[List[str], int]: - """ - Collect lines for an if statement (if ... fi). - - Returns (collected_lines, end_index) - """ - lines = [commands[start]] - depth = 1 - i = start + 1 - - while i < len(commands): - line = commands[i] - line_no_comment = self._strip_comment(line).strip() - lines.append(line) - - if line_no_comment.startswith('if '): - depth += 1 - elif line_no_comment == 'fi': - depth -= 1 - if depth == 0: - break - i += 1 - - return lines, i diff --git a/third_party/agfs/agfs-shell/agfs_shell/executor.py b/third_party/agfs/agfs-shell/agfs_shell/executor.py deleted file mode 100644 index 767e314cd..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/executor.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -AST Executor for shell control flow structures. - -This module executes AST nodes and handles control flow properly -using Python exceptions for break/continue/return. -""" - -from typing import List, TYPE_CHECKING -from .ast_nodes import ( - Statement, CommandStatement, - ForStatement, WhileStatement, UntilStatement, - IfStatement, FunctionDefinition -) -from .control_flow import ( - BreakException, ContinueException, ReturnException -) - -if TYPE_CHECKING: - from .shell import Shell - - -class ShellExecutor: - """ - Executes AST nodes in the context of a Shell instance. - - This class handles proper control flow propagation using exceptions. - """ - - def __init__(self, shell: 'Shell'): - """ - Initialize executor. - - Args: - shell: Shell instance for command execution and variable access - """ - self.shell = shell - self.loop_depth = 0 # Current loop nesting depth - self.function_depth = 0 # Current function nesting depth - - # ======================================================================== - # Main Entry Point - # ======================================================================== - - def execute_statement(self, stmt: Statement) -> int: - """ - Execute a single statement. - - Args: - stmt: Statement AST node - - Returns: - Exit code of the statement - """ - if isinstance(stmt, CommandStatement): - return self.execute_command(stmt) - elif isinstance(stmt, ForStatement): - return self.execute_for(stmt) - elif isinstance(stmt, WhileStatement): - return self.execute_while(stmt) - elif isinstance(stmt, UntilStatement): - return self.execute_until(stmt) - elif isinstance(stmt, IfStatement): - return self.execute_if(stmt) - elif isinstance(stmt, FunctionDefinition): - return self.execute_function_def(stmt) - else: - # Unknown statement type - return 0 - - def execute_block(self, statements: List[Statement]) -> int: - """ - Execute a block of statements. - - Break/Continue/Return exceptions propagate through. - - Args: - statements: List of Statement AST nodes - - Returns: - Exit code of last executed statement - """ - last_exit_code = 0 - - for stmt in statements: - last_exit_code = self.execute_statement(stmt) - - return last_exit_code - - # ======================================================================== - # Statement Executors - # ======================================================================== - - def execute_command(self, stmt: CommandStatement) -> int: - """ - Execute a simple command. - - This delegates to shell.execute() for actual command execution. - """ - return self.shell.execute(stmt.command) - - def execute_for(self, stmt: ForStatement) -> int: - """ - Execute a for loop. - - Example: - for i in 1 2 3; do echo $i; done - """ - # Expand items (variable expansion, glob expansion) - items_str = self.shell._expand_variables(stmt.items_raw) - items = items_str.split() - - # Expand globs - expanded_items = [] - for item in items: - if '*' in item or '?' in item or '[' in item: - matches = self.shell._match_glob_pattern(item) - if matches: - expanded_items.extend(sorted(matches)) - else: - expanded_items.append(item) - else: - expanded_items.append(item) - - last_exit_code = 0 - self.loop_depth += 1 - - try: - for item in expanded_items: - # Set loop variable - self.shell.env[stmt.variable] = item - - try: - last_exit_code = self.execute_block(stmt.body) - except ContinueException as e: - if e.levels <= 1: - # Continue to next iteration - continue - else: - # Propagate to outer loop - e.levels -= 1 - raise - except BreakException as e: - if e.levels <= 1: - # Break out of this loop - break - else: - # Propagate to outer loop - e.levels -= 1 - raise - finally: - self.loop_depth -= 1 - - return last_exit_code - - def execute_while(self, stmt: WhileStatement) -> int: - """ - Execute a while loop. - - Example: - while test $i -lt 10; do echo $i; i=$((i+1)); done - """ - last_exit_code = 0 - self.loop_depth += 1 - - try: - while True: - # Evaluate condition - cond_code = self.shell.execute(stmt.condition) - - # Exit if condition is false (non-zero) - if cond_code != 0: - break - - # Execute loop body - try: - last_exit_code = self.execute_block(stmt.body) - except ContinueException as e: - if e.levels <= 1: - # Continue to next iteration - continue - else: - # Propagate to outer loop - e.levels -= 1 - raise - except BreakException as e: - if e.levels <= 1: - # Break out of this loop - break - else: - # Propagate to outer loop - e.levels -= 1 - raise - finally: - self.loop_depth -= 1 - - return last_exit_code - - def execute_until(self, stmt: UntilStatement) -> int: - """ - Execute an until loop (opposite of while). - - Example: - until test $i -ge 10; do echo $i; i=$((i+1)); done - """ - last_exit_code = 0 - self.loop_depth += 1 - - try: - while True: - # Evaluate condition - cond_code = self.shell.execute(stmt.condition) - - # Exit if condition is true (zero) - if cond_code == 0: - break - - # Execute loop body - try: - last_exit_code = self.execute_block(stmt.body) - except ContinueException as e: - if e.levels <= 1: - continue - else: - e.levels -= 1 - raise - except BreakException as e: - if e.levels <= 1: - break - else: - e.levels -= 1 - raise - finally: - self.loop_depth -= 1 - - return last_exit_code - - def execute_if(self, stmt: IfStatement) -> int: - """ - Execute an if statement. - - Example: - if test $x -eq 1; then echo one; elif test $x -eq 2; then echo two; else echo other; fi - """ - # Try each branch - for branch in stmt.branches: - cond_code = self.shell.execute(branch.condition) - - if cond_code == 0: - # Condition is true, execute this branch - return self.execute_block(branch.body) - - # No branch matched, try else - if stmt.else_body: - return self.execute_block(stmt.else_body) - - return 0 - - def execute_function_def(self, stmt: FunctionDefinition) -> int: - """ - Register a function definition. - - Note: This doesn't execute the function, just stores it. - """ - self.shell.functions[stmt.name] = { - 'name': stmt.name, - 'body': stmt.body, # Store AST body - 'is_ast': True # Flag to indicate AST-based function - } - return 0 - - # ======================================================================== - # Function Execution - # ======================================================================== - - def execute_function_call(self, func_name: str, args: List[str]) -> int: - """ - Execute a user-defined function. - - This handles: - - Parameter passing ($1, $2, etc.) - - Local variable scope management - - _function_depth tracking for nested functions - - Return value handling via ReturnException - - Proper cleanup on exit - - Args: - func_name: Name of the function to call - args: Arguments to pass to the function - - Returns: - Exit code from the function - """ - if func_name not in self.shell.functions: - return 127 - - func_def = self.shell.functions[func_name] - - # Save current positional parameters - saved_params = {} - for key in list(self.shell.env.keys()): - if key.isdigit() or key in ('#', '@', '*', '0'): - saved_params[key] = self.shell.env[key] - - # Track function depth for local command - current_depth = int(self.shell.env.get('_function_depth', '0')) - self.shell.env['_function_depth'] = str(current_depth + 1) - - # Save local variables that will be shadowed - saved_locals = {} - for key in list(self.shell.env.keys()): - if key.startswith('_local_'): - saved_locals[key] = self.shell.env[key] - - # Set up function environment (positional parameters) - self.shell.env['0'] = func_name - self.shell.env['#'] = str(len(args)) - self.shell.env['@'] = ' '.join(args) - self.shell.env['*'] = ' '.join(args) - for i, arg in enumerate(args, 1): - self.shell.env[str(i)] = arg - - # Push a new local scope - if hasattr(self.shell, 'local_scopes'): - self.shell.local_scopes.append({}) - - self.function_depth += 1 - last_code = 0 - - try: - # Execute function body - if func_def.get('is_ast', False): - # AST-based function - last_code = self.execute_block(func_def['body']) - else: - # Legacy list-based function (for backward compatibility) - for cmd in func_def['body']: - last_code = self.shell.execute(cmd) - - except ReturnException as e: - last_code = e.exit_code - - except (BreakException, ContinueException): - self.shell.console.print( - f"[red]{func_name}: break/continue only meaningful in a loop[/red]", - highlight=False - ) - last_code = 1 - - finally: - self.function_depth -= 1 - - # Pop local scope - if hasattr(self.shell, 'local_scopes') and self.shell.local_scopes: - self.shell.local_scopes.pop() - - # Clear local variables from this function - for key in list(self.shell.env.keys()): - if key.startswith('_local_'): - del self.shell.env[key] - - # Restore saved local variables - for key, value in saved_locals.items(): - self.shell.env[key] = value - - # Restore function depth - self.shell.env['_function_depth'] = str(current_depth) - if current_depth == 0: - # Clean up if we're exiting the outermost function - if '_function_depth' in self.shell.env: - del self.shell.env['_function_depth'] - - # Restore positional parameters - # First, remove all current positional params - for key in list(self.shell.env.keys()): - if key.isdigit() or key in ('#', '@', '*', '0'): - del self.shell.env[key] - - # Then restore saved ones - self.shell.env.update(saved_params) - - return last_code diff --git a/third_party/agfs/agfs-shell/agfs_shell/exit_codes.py b/third_party/agfs/agfs-shell/agfs_shell/exit_codes.py deleted file mode 100644 index 92a4fe207..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/exit_codes.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Special exit codes for shell control flow and internal signaling""" - -# Control flow exit codes (used by break/continue) -EXIT_CODE_CONTINUE = -995 # Signal continue statement in loop -EXIT_CODE_BREAK = -996 # Signal break statement in loop - -# Collection signal codes (used by REPL to collect multi-line constructs) -EXIT_CODE_FOR_LOOP_NEEDED = -997 # Signal that for loop needs to be collected -EXIT_CODE_WHILE_LOOP_NEEDED = -994 # Signal that while loop needs to be collected -EXIT_CODE_IF_STATEMENT_NEEDED = -998 # Signal that if statement needs to be collected -EXIT_CODE_HEREDOC_NEEDED = -999 # Signal that heredoc data needs to be read -EXIT_CODE_FUNCTION_DEF_NEEDED = -1000 # Signal that function definition needs to be collected -EXIT_CODE_RETURN = -1001 # Signal return statement in function diff --git a/third_party/agfs/agfs-shell/agfs_shell/expression.py b/third_party/agfs/agfs-shell/agfs_shell/expression.py deleted file mode 100644 index 865bfd8ad..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/expression.py +++ /dev/null @@ -1,1064 +0,0 @@ -""" -Expression evaluation framework for shell - -This module provides a unified framework for evaluating shell expressions: -- Variable expansion: $VAR, ${VAR}, ${VAR:-default}, etc. -- Arithmetic evaluation: $((expr)) -- Command substitution: $(cmd), `cmd` -- Escape sequences: $'...' syntax and backslash escapes - -Design principles: -- Single source of truth for expansion logic -- Reusable components (BracketMatcher, QuoteTracker) -- Support for Bash-style parameter expansion modifiers -- Safe arithmetic evaluation without eval() -""" - -import re -import ast -import operator -from typing import Optional, Callable, Tuple, List, TYPE_CHECKING -from dataclasses import dataclass -from .lexer import QuoteTracker - -if TYPE_CHECKING: - from .shell import Shell - - -# ============================================================================= -# Utility Classes -# ============================================================================= - -class EscapeHandler: - """ - Handles escape sequences in shell strings - - Supports: - - $'...' ANSI-C quoting syntax (full escape support) - - Backslash escapes in double quotes (limited: \\\\, \\$, \\`, \\", \\newline) - - Escape sequences supported in $'...': - - \\n newline - - \\t tab - - \\r carriage return - - \\a alert (bell) - - \\b backspace - - \\e escape character - - \\f form feed - - \\v vertical tab - - \\\\ backslash - - \\' single quote - - \\" double quote - - \\xHH hex byte - - \\nnn octal byte - """ - - # Escape sequences for $'...' syntax - ESCAPE_MAP = { - 'n': '\n', - 't': '\t', - 'r': '\r', - 'a': '\a', - 'b': '\b', - 'e': '\x1b', # escape character - 'f': '\f', - 'v': '\v', - '\\': '\\', - "'": "'", - '"': '"', - '0': '\0', - } - - @classmethod - def process_escapes(cls, text: str) -> str: - """ - Process escape sequences in text - - Args: - text: Text that may contain escape sequences - - Returns: - Text with escape sequences expanded - """ - result = [] - i = 0 - - while i < len(text): - if text[i] == '\\' and i + 1 < len(text): - next_char = text[i + 1] - - # Check simple escapes - if next_char in cls.ESCAPE_MAP: - result.append(cls.ESCAPE_MAP[next_char]) - i += 2 - continue - - # Hex escape: \xHH - if next_char == 'x' and i + 3 < len(text): - hex_digits = text[i+2:i+4] - if all(c in '0123456789abcdefABCDEF' for c in hex_digits): - result.append(chr(int(hex_digits, 16))) - i += 4 - continue - - # Octal escape: \nnn (1-3 digits) - if next_char in '0123456789': - octal = '' - j = i + 1 - while j < len(text) and j < i + 4 and text[j] in '01234567': - octal += text[j] - j += 1 - if octal: - value = int(octal, 8) - if value <= 255: - result.append(chr(value)) - i = j - continue - - # Unknown escape - keep as is - result.append(text[i]) - i += 1 - else: - result.append(text[i]) - i += 1 - - return ''.join(result) - - @classmethod - def expand_dollar_single_quotes(cls, text: str) -> str: - """ - Expand $'...' ANSI-C quoting syntax - - Args: - text: Text that may contain $'...' sequences - - Returns: - Text with $'...' expanded (quotes removed, escapes processed) - """ - result = [] - i = 0 - - while i < len(text): - # Look for $' - if text[i:i+2] == "$'": - # Find matching closing quote - start = i - i += 2 - content = [] - - while i < len(text): - if text[i] == '\\' and i + 1 < len(text): - # Escape sequence - include both chars for later processing - content.append(text[i:i+2]) - i += 2 - elif text[i] == "'": - # End of $'...' - escaped_content = cls.process_escapes(''.join(content)) - result.append(escaped_content) - i += 1 - break - else: - content.append(text[i]) - i += 1 - else: - # Unclosed $' - keep original - result.append(text[start:]) - else: - result.append(text[i]) - i += 1 - - return ''.join(result) - - # Limited escapes allowed in double quotes (Bash behavior) - DOUBLE_QUOTE_ESCAPES = {'\\', '$', '"', '`', '\n'} - - # Placeholder for escaped characters (to prevent re-expansion) - # Using private use area characters that won't appear in normal text - ESCAPED_DOLLAR = '\ue000' - ESCAPED_BACKTICK = '\ue001' - ESCAPED_BACKSLASH = '\ue002' - - @classmethod - def process_double_quote_escapes(cls, text: str) -> str: - """ - Process escape sequences inside double-quoted strings - - In Bash, only these escapes are special inside double quotes: - - \\\\ literal backslash - - \\$ literal dollar sign - - \\" literal double quote - - \\` literal backtick - - \\newline line continuation (removed) - - Other \\X sequences are kept as-is (backslash is preserved). - - Args: - text: Content inside double quotes (without the quotes) - - Returns: - Text with escapes processed - """ - result = [] - i = 0 - - while i < len(text): - if text[i] == '\\' and i + 1 < len(text): - next_char = text[i + 1] - if next_char in cls.DOUBLE_QUOTE_ESCAPES: - if next_char == '\n': - # Line continuation - skip both backslash and newline - i += 2 - continue - else: - # Valid escape - output just the character - result.append(next_char) - i += 2 - continue - # Not a valid escape in double quotes - keep backslash - result.append(text[i]) - i += 1 - else: - result.append(text[i]) - i += 1 - - return ''.join(result) - - @classmethod - def expand_double_quote_escapes(cls, text: str) -> str: - """ - Process escapes inside double-quoted portions of text - - Finds "..." sections and processes escapes within them. - Uses placeholders for escaped $, `, \\ to prevent re-expansion. - - Args: - text: Full text that may contain double-quoted strings - - Returns: - Text with double-quote escapes processed (placeholders used) - """ - result = [] - i = 0 - in_single_quote = False - - while i < len(text): - char = text[i] - - # Track single quotes (no escape processing inside) - if char == "'" and not in_single_quote: - # Check if this is $'...' which is handled separately - if i > 0 and text[i-1] == '$': - result.append(char) - i += 1 - continue - in_single_quote = True - result.append(char) - i += 1 - continue - elif char == "'" and in_single_quote: - in_single_quote = False - result.append(char) - i += 1 - continue - - if in_single_quote: - result.append(char) - i += 1 - continue - - # Handle double quotes - if char == '"': - result.append(char) # Keep opening quote - i += 1 - content = [] - - # Collect content until closing quote - while i < len(text): - if text[i] == '\\' and i + 1 < len(text): - next_char = text[i + 1] - if next_char in cls.DOUBLE_QUOTE_ESCAPES: - if next_char == '\n': - # Line continuation - skip both - i += 2 - continue - elif next_char == '$': - # Use placeholder to prevent variable expansion - content.append(cls.ESCAPED_DOLLAR) - i += 2 - continue - elif next_char == '`': - # Use placeholder to prevent command substitution - content.append(cls.ESCAPED_BACKTICK) - i += 2 - continue - elif next_char == '\\': - # Use placeholder - content.append(cls.ESCAPED_BACKSLASH) - i += 2 - continue - else: - # Valid escape (like \") - content.append(next_char) - i += 2 - continue - # Not valid - keep backslash and char - content.append(text[i]) - i += 1 - elif text[i] == '"': - # End of double quote - result.append(''.join(content)) - result.append('"') # Keep closing quote - i += 1 - break - else: - content.append(text[i]) - i += 1 - else: - # Unclosed quote - append what we have - result.append(''.join(content)) - else: - result.append(char) - i += 1 - - return ''.join(result) - - @classmethod - def restore_escaped_chars(cls, text: str) -> str: - """ - Restore placeholder characters to their original values - - Called after all expansions are complete. - """ - return (text - .replace(cls.ESCAPED_DOLLAR, '$') - .replace(cls.ESCAPED_BACKTICK, '`') - .replace(cls.ESCAPED_BACKSLASH, '\\')) - - -class BracketMatcher: - """ - Utility class for finding matching brackets/parentheses in text - - Handles: - - Nested brackets - - Quote-awareness (brackets inside quotes don't count) - - Multiple bracket types: (), {}, [] - """ - - BRACKETS = { - '(': ')', - '{': '}', - '[': ']', - } - - @classmethod - def find_matching_close(cls, text: str, open_pos: int) -> int: - """ - Find the position of the matching closing bracket - - Args: - text: Text to search in - open_pos: Position of the opening bracket - - Returns: - Position of matching closing bracket, or -1 if not found - """ - if open_pos >= len(text): - return -1 - - open_char = text[open_pos] - if open_char not in cls.BRACKETS: - return -1 - - close_char = cls.BRACKETS[open_char] - depth = 1 - tracker = QuoteTracker() - - i = open_pos + 1 - while i < len(text): - char = text[i] - tracker.process_char(char) - - if not tracker.is_quoted(): - if char == open_char: - depth += 1 - elif char == close_char: - depth -= 1 - if depth == 0: - return i - i += 1 - - return -1 - - @classmethod - def extract_balanced(cls, text: str, start: int, - open_char: str, close_char: str) -> Tuple[str, int]: - """ - Extract content between balanced brackets - - Args: - text: Text to extract from - start: Position of opening bracket - open_char: Opening bracket character - close_char: Closing bracket character - - Returns: - Tuple of (content between brackets, position after closing bracket) - Returns ('', start) if not found - """ - if start >= len(text) or text[start] != open_char: - return '', start - - depth = 1 - tracker = QuoteTracker() - content = [] - i = start + 1 - - while i < len(text): - char = text[i] - tracker.process_char(char) - - if not tracker.is_quoted(): - if char == open_char: - depth += 1 - elif char == close_char: - depth -= 1 - if depth == 0: - return ''.join(content), i + 1 - - content.append(char) - i += 1 - - # Unbalanced - return what we have - return ''.join(content), i - - -# ============================================================================= -# Parameter Expansion -# ============================================================================= - -@dataclass -class ParameterExpansion: - """ - Represents a parameter expansion like ${VAR:-default} - - Attributes: - var_name: Variable name - modifier: Modifier character (-, +, =, ?, #, %, /) - modifier_arg: Argument to modifier (e.g., default value) - greedy: Whether modifier is greedy (## vs #, %% vs %) - """ - var_name: str - modifier: Optional[str] = None - modifier_arg: Optional[str] = None - greedy: bool = False - - -class ParameterExpander: - """ - Handles Bash-style parameter expansion - - Supports: - - ${VAR} - Simple expansion - - ${VAR:-default} - Use default if unset or null - - ${VAR:=default} - Assign default if unset or null - - ${VAR:+value} - Use value if set and non-null - - ${VAR:?error} - Error if unset or null - - ${VAR#pattern} - Remove shortest prefix matching pattern - - ${VAR##pattern} - Remove longest prefix matching pattern - - ${VAR%pattern} - Remove shortest suffix matching pattern - - ${VAR%%pattern} - Remove longest suffix matching pattern - - ${#VAR} - String length - """ - - # Pattern for parsing ${...} content - # Matches: VAR, VAR:-default, VAR#pattern, #VAR, etc. - MODIFIER_PATTERN = re.compile( - r'^(?P#)?' # Optional # for length - r'(?P[A-Za-z_][A-Za-z0-9_]*|\d+)' # Variable name or positional - r'(?::?(?P[-+=?#%])(?P[#%])?(?P.*))?$' # Optional modifier - ) - - def __init__(self, get_variable: Callable[[str], str], - set_variable: Optional[Callable[[str, str], None]] = None): - """ - Initialize expander - - Args: - get_variable: Function to get variable value - set_variable: Function to set variable value (for := modifier) - """ - self.get_variable = get_variable - self.set_variable = set_variable - - def parse(self, content: str) -> Optional[ParameterExpansion]: - """ - Parse parameter expansion content (without ${}) - - Args: - content: Content inside ${} - - Returns: - ParameterExpansion object or None if invalid - """ - # Handle ${#VAR} (length) - if content.startswith('#') and len(content) > 1: - var_name = content[1:] - if re.match(r'^[A-Za-z_][A-Za-z0-9_]*$|^\d+$', var_name): - return ParameterExpansion(var_name=var_name, modifier='length') - - # Try to match modifier patterns - match = self.MODIFIER_PATTERN.match(content) - if not match: - # Simple variable name? - if re.match(r'^[A-Za-z_][A-Za-z0-9_]*$|^\d+$', content): - return ParameterExpansion(var_name=content) - return None - - var_name = match.group('name') - modifier = match.group('mod') - greedy = bool(match.group('greedy')) - arg = match.group('arg') or '' - - # Check for length prefix - if match.group('length'): - return ParameterExpansion(var_name=var_name, modifier='length') - - return ParameterExpansion( - var_name=var_name, - modifier=modifier, - modifier_arg=arg, - greedy=greedy - ) - - def expand(self, expansion: ParameterExpansion) -> str: - """ - Evaluate a parameter expansion - - Args: - expansion: Parsed expansion - - Returns: - Expanded value - """ - value = self.get_variable(expansion.var_name) - - if expansion.modifier is None: - return value - - if expansion.modifier == 'length': - return str(len(value)) - - if expansion.modifier == '-': - # ${VAR:-default} - use default if empty - return value if value else expansion.modifier_arg - - if expansion.modifier == '+': - # ${VAR:+value} - use value if set - return expansion.modifier_arg if value else '' - - if expansion.modifier == '=': - # ${VAR:=default} - assign default if empty - if not value: - value = expansion.modifier_arg - if self.set_variable: - self.set_variable(expansion.var_name, value) - return value - - if expansion.modifier == '?': - # ${VAR:?error} - error if empty - if not value: - # In a real shell, this would print error and exit - # For now, just return empty - return '' - return value - - if expansion.modifier == '#': - # ${VAR#pattern} or ${VAR##pattern} - remove prefix - pattern = expansion.modifier_arg - if expansion.greedy: - # Remove longest matching prefix - return self._remove_prefix_greedy(value, pattern) - else: - # Remove shortest matching prefix - return self._remove_prefix(value, pattern) - - if expansion.modifier == '%': - # ${VAR%pattern} or ${VAR%%pattern} - remove suffix - pattern = expansion.modifier_arg - if expansion.greedy: - return self._remove_suffix_greedy(value, pattern) - else: - return self._remove_suffix(value, pattern) - - return value - - def _glob_to_regex(self, pattern: str) -> str: - """Convert shell glob pattern to regex""" - result = [] - i = 0 - while i < len(pattern): - c = pattern[i] - if c == '*': - result.append('.*') - elif c == '?': - result.append('.') - elif c in '.^$+{}[]|()\\': - result.append('\\' + c) - else: - result.append(c) - i += 1 - return ''.join(result) - - def _remove_prefix(self, value: str, pattern: str) -> str: - """Remove shortest matching prefix""" - regex = '^' + self._glob_to_regex(pattern) - match = re.match(regex, value) - if match: - # Find shortest match - for i in range(1, len(value) + 1): - if re.match(regex + '$', value[:i]): - return value[i:] - return value[match.end():] - return value - - def _remove_prefix_greedy(self, value: str, pattern: str) -> str: - """Remove longest matching prefix""" - regex = '^' + self._glob_to_regex(pattern) - match = re.match(regex, value) - if match: - return value[match.end():] - return value - - def _remove_suffix(self, value: str, pattern: str) -> str: - """Remove shortest matching suffix""" - regex = self._glob_to_regex(pattern) + '$' - match = re.search(regex, value) - if match: - # Find shortest match by trying from end - for i in range(len(value) - 1, -1, -1): - if re.match('^' + self._glob_to_regex(pattern) + '$', value[i:]): - return value[:i] - return value[:match.start()] - return value - - def _remove_suffix_greedy(self, value: str, pattern: str) -> str: - """Remove longest matching suffix""" - regex = self._glob_to_regex(pattern) + '$' - match = re.search(regex, value) - if match: - return value[:match.start()] - return value - - -# ============================================================================= -# Arithmetic Evaluation -# ============================================================================= - -class ArithmeticEvaluator: - """ - Safe arithmetic expression evaluator - - Uses Python's AST to safely evaluate arithmetic expressions - without using dangerous eval(). - - Supports: - - Basic operators: +, -, *, /, %, ** - - Unary operators: +, - - - Parentheses - - Integer and float literals - - Variable references (via callback) - """ - - ALLOWED_OPS = { - ast.Add: operator.add, - ast.Sub: operator.sub, - ast.Mult: operator.mul, - ast.Div: operator.truediv, - ast.FloorDiv: operator.floordiv, - ast.Mod: operator.mod, - ast.Pow: operator.pow, - ast.USub: operator.neg, - ast.UAdd: operator.pos, - } - - def __init__(self, get_variable: Callable[[str], str]): - """ - Initialize evaluator - - Args: - get_variable: Function to get variable value - """ - self.get_variable = get_variable - - def evaluate(self, expr: str) -> int: - """ - Evaluate an arithmetic expression - - Args: - expr: Expression string (e.g., "5 + 3 * 2") - - Returns: - Integer result (Bash arithmetic uses integers) - """ - try: - # Expand variables in expression - expanded = self._expand_variables(expr) - - # Parse and evaluate - tree = ast.parse(expanded.strip(), mode='eval') - result = self._eval_node(tree.body) - - return int(result) - except Exception: - # Any error returns 0 (Bash behavior) - return 0 - - def _expand_variables(self, expr: str) -> str: - """Expand variables in arithmetic expression""" - result = expr - - # Expand ${VAR} format - for match in re.finditer(r'\$\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}', expr): - var_name = match.group(1) - value = self._get_numeric_value(var_name) - result = result.replace(f'${{{var_name}}}', value) - - # Expand $VAR and $N format - for match in re.finditer(r'\$([A-Za-z_][A-Za-z0-9_]*|\d+)', result): - var_name = match.group(1) - value = self._get_numeric_value(var_name) - result = result.replace(f'${var_name}', value) - - # Expand bare variable names (VAR without $) - # Be careful not to replace keywords - keywords = {'and', 'or', 'not', 'in', 'is', 'if', 'else'} - for match in re.finditer(r'\b([A-Za-z_][A-Za-z0-9_]*)\b', result): - var_name = match.group(1) - if var_name in keywords: - continue - value = self.get_variable(var_name) - if value: - try: - int(value) - result = result.replace(var_name, value) - except ValueError: - pass - - return result - - def _get_numeric_value(self, var_name: str) -> str: - """Get variable value as numeric string""" - value = self.get_variable(var_name) or '0' - try: - int(value) - return value - except ValueError: - return '0' - - def _eval_node(self, node) -> float: - """Recursively evaluate AST node""" - if isinstance(node, ast.Constant): - if isinstance(node.value, (int, float)): - return node.value - raise ValueError(f"Only numeric constants allowed, got {type(node.value)}") - - # Python 3.7 compatibility - if hasattr(ast, 'Num') and isinstance(node, ast.Num): - return node.n - - if isinstance(node, ast.BinOp): - if type(node.op) not in self.ALLOWED_OPS: - raise ValueError(f"Operator {type(node.op).__name__} not allowed") - left = self._eval_node(node.left) - right = self._eval_node(node.right) - return self.ALLOWED_OPS[type(node.op)](left, right) - - if isinstance(node, ast.UnaryOp): - if type(node.op) not in self.ALLOWED_OPS: - raise ValueError(f"Operator {type(node.op).__name__} not allowed") - operand = self._eval_node(node.operand) - return self.ALLOWED_OPS[type(node.op)](operand) - - raise ValueError(f"Node type {type(node).__name__} not allowed") - - -# ============================================================================= -# Main Expression Expander -# ============================================================================= - -class ExpressionExpander: - """ - Main class for expanding all types of shell expressions - - This is the unified entry point for expression expansion. - It handles the correct order of expansions: - 1. Command substitution $(cmd) and `cmd` - 2. Arithmetic expansion $((expr)) - 3. Parameter expansion ${VAR}, $VAR - - Usage: - expander = ExpressionExpander(shell) - result = expander.expand("Hello ${USER:-world}! Sum: $((1+2))") - """ - - def __init__(self, shell: 'Shell'): - """ - Initialize expander with shell context - - Args: - shell: Shell instance for variable access and command execution - """ - self.shell = shell - self.param_expander = ParameterExpander( - get_variable=shell._get_variable, - set_variable=lambda n, v: shell._set_variable(n, v) - ) - self.arith_evaluator = ArithmeticEvaluator( - get_variable=shell._get_variable - ) - - def expand(self, text: str) -> str: - """ - Expand all expressions in text - - Expansion order: - 1. $'...' ANSI-C quoting (escape sequences) - 2. Double-quote escape processing (backslash escapes) - 3. Command substitution $(cmd) and `cmd` - 4. Arithmetic $((expr)) - 5. Parameter expansion ${VAR}, $VAR - - Args: - text: Text containing expressions - - Returns: - Fully expanded text - """ - # Step 1: $'...' ANSI-C quoting with escape sequences - text = EscapeHandler.expand_dollar_single_quotes(text) - - # Step 2: Command substitution - text = self._expand_command_substitution(text) - - # Step 3: Arithmetic expansion - text = self._expand_arithmetic(text) - - # Step 4: Parameter expansion - text = self._expand_parameters(text) - - return text - - def expand_variables_only(self, text: str) -> str: - """ - Expand only variable references, not command substitution - - Useful for contexts where command substitution shouldn't happen. - """ - text = self._expand_arithmetic(text) - text = self._expand_parameters(text) - return text - - def _expand_command_substitution(self, text: str) -> str: - """Expand $(cmd) and `cmd` substitutions""" - # First, protect escaped backticks - ESCAPED_BACKTICK = '\ue001' - text = text.replace('\\`', ESCAPED_BACKTICK) - - # Handle $(cmd) - process innermost first - max_iterations = 10 - for _ in range(max_iterations): - result = self._find_innermost_command_subst(text) - if result is None: - break - start, end, command = result - output = self._execute_command_substitution(command) - text = text[:start] + output + text[end:] - - # Handle `cmd` (backticks) - only unescaped ones - def replace_backtick(match): - command = match.group(1) - return self._execute_command_substitution(command) - - text = re.sub(r'`([^`]+)`', replace_backtick, text) - - # Restore escaped backticks - text = text.replace(ESCAPED_BACKTICK, '`') - - return text - - def _find_innermost_command_subst(self, text: str) -> Optional[Tuple[int, int, str]]: - """Find the innermost $(command) substitution""" - tracker = QuoteTracker() - i = 0 - - while i < len(text) - 1: - char = text[i] - tracker.process_char(char) - - if not tracker.is_quoted() and text[i:i+2] == '$(': - # Skip if this is $(( - if i < len(text) - 2 and text[i:i+3] == '$((': - i += 1 - continue - - # Found $(, find matching ) - start = i - content, end = BracketMatcher.extract_balanced(text, i + 1, '(', ')') - - if end > i + 1: - # Check if there are nested $( inside - if '$(' in content and '$((' not in content: - # Has nested - recurse to find innermost - i += 2 - continue - return (start, end, content) - - i += 1 - - return None - - def _execute_command_substitution(self, command: str) -> str: - """Execute a command and return its output""" - # Delegate to shell's implementation - return self.shell._execute_command_substitution(command) - - def _expand_arithmetic(self, text: str) -> str: - """Expand $((expr)) arithmetic expressions, handling nesting""" - # Process from innermost to outermost - max_iterations = 10 - for _ in range(max_iterations): - # Find innermost $((..)) - result = self._find_innermost_arithmetic(text) - if result is None: - break - - start, end, expr = result - # Evaluate and replace - value = self.arith_evaluator.evaluate(expr) - text = text[:start] + str(value) + text[end:] - - return text - - def _find_innermost_arithmetic(self, text: str) -> Optional[Tuple[int, int, str]]: - """Find the innermost $((expr)) for evaluation""" - # Find all $(( positions - i = 0 - candidates = [] - - while i < len(text) - 2: - if text[i:i+3] == '$((': - candidates.append(i) - i += 1 - - if not candidates: - return None - - # For each candidate, check if it's innermost (no nested $(( inside) - for start in reversed(candidates): - # Find matching )) - depth = 2 # We've seen $(( - j = start + 3 - expr_start = j - - while j < len(text) and depth > 0: - if text[j:j+3] == '$((': - depth += 2 - j += 3 - continue - elif text[j:j+2] == '))' and depth >= 2: - depth -= 2 - if depth == 0: - # Found matching )) - expr = text[expr_start:j] - # Check if this expression contains nested $(( - if '$((' not in expr: - return (start, j + 2, expr) - j += 2 - continue - elif text[j] == '(': - depth += 1 - elif text[j] == ')': - depth -= 1 - j += 1 - - # Try simpler approach: find first $(( without nested $(( - for start in candidates: - depth = 2 - j = start + 3 - expr_start = j - - while j < len(text) and depth > 0: - if text[j] == '(': - depth += 1 - elif text[j] == ')': - depth -= 1 - j += 1 - - if depth == 0: - expr = text[expr_start:j-2] - if '$((' not in expr: - return (start, j, expr) - - return None - - def _expand_parameters(self, text: str) -> str: - """Expand ${VAR} and $VAR parameter references""" - # First, protect escaped dollars (\$) by replacing with placeholder - # This handles cases like "cost: \$100" where \$ should be literal $ - ESCAPED_DOLLAR_PLACEHOLDER = '\ue000' - text = text.replace('\\$', ESCAPED_DOLLAR_PLACEHOLDER) - - # Expand special variables - text = text.replace('$?', self.shell._get_variable('?')) - text = text.replace('$#', self.shell._get_variable('#')) - text = text.replace('$@', self.shell._get_variable('@')) - text = text.replace('$*', self.shell._get_variable('*')) - text = text.replace('$0', self.shell._get_variable('0')) - - # Expand ${...} with modifiers - text = self._expand_braced_parameters(text) - - # Expand $N (positional parameters) - def replace_positional(match): - return self.shell._get_variable(match.group(1)) - text = re.sub(r'\$(\d+)', replace_positional, text) - - # Expand $VAR (simple variables) - def replace_simple(match): - return self.shell._get_variable(match.group(1)) - text = re.sub(r'\$([A-Za-z_][A-Za-z0-9_]*)', replace_simple, text) - - # Restore escaped dollar - text = text.replace(ESCAPED_DOLLAR_PLACEHOLDER, '$') - - return text - - def _expand_braced_parameters(self, text: str) -> str: - """Expand ${...} parameter expansions with modifiers""" - result = [] - i = 0 - - while i < len(text): - if i < len(text) - 1 and text[i:i+2] == '${': - # Find matching } - content, end = BracketMatcher.extract_balanced(text, i + 1, '{', '}') - - if end > i + 1: - # Parse and expand - expansion = self.param_expander.parse(content) - if expansion: - value = self.param_expander.expand(expansion) - result.append(value) - else: - # Invalid, keep original - result.append(text[i:end]) - i = end - else: - result.append(text[i]) - i += 1 - else: - result.append(text[i]) - i += 1 - - return ''.join(result) diff --git a/third_party/agfs/agfs-shell/agfs_shell/filesystem.py b/third_party/agfs/agfs-shell/agfs_shell/filesystem.py deleted file mode 100644 index 15a4488bf..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/filesystem.py +++ /dev/null @@ -1,238 +0,0 @@ -"""AGFS File System abstraction layer""" - -from typing import BinaryIO, Iterator, Optional, Union - -from pyagfs import AGFSClient, AGFSClientError - - -class AGFSFileSystem: - """Abstraction layer for AGFS file system operations""" - - def __init__(self, server_url: str = "http://localhost:8080", timeout: int = 30): - """ - Initialize AGFS file system - - Args: - server_url: AGFS server URL (default: http://localhost:8080) - timeout: Request timeout in seconds (default: 30) - - Increased from 5 to 30 for better support of large file transfers - - Each 8KB chunk upload/download should complete within this time - """ - self.server_url = server_url - self.client = AGFSClient(server_url, timeout=timeout) - self._connected = False - - def check_connection(self) -> bool: - """Check if AGFS server is accessible""" - if self._connected: - return True - - try: - self.client.health() - self._connected = True - return True - except Exception: - # Catch all exceptions (ConnectionError, AGFSClientError, etc.) - return False - - def read_file( - self, path: str, offset: int = 0, size: int = -1, stream: bool = False - ) -> Union[bytes, Iterator[bytes]]: - """ - Read file content from AGFS - - Args: - path: File path in AGFS - offset: Starting byte offset (default: 0) - size: Number of bytes to read, -1 for all (default: -1) - stream: If True, return iterator for streaming; if False, return all content - - Returns: - If stream=False: File content as bytes - If stream=True: Iterator yielding chunks of bytes - - Raises: - AGFSClientError: If file cannot be read - """ - try: - if stream: - # Try streaming mode on server side first - try: - response = self.client.cat( - path, offset=offset, size=size, stream=True - ) - return response.iter_content(chunk_size=8192) - except AGFSClientError as e: - # Fallback to regular read and simulate streaming - content = self.client.cat( - path, offset=offset, size=size, stream=False - ) - - # Return iterator that yields chunks - def chunk_generator(data, chunk_size=8192): - for i in range(0, len(data), chunk_size): - yield data[i : i + chunk_size] - - return chunk_generator(content) - else: - # Return all content at once - return self.client.cat(path, offset=offset, size=size) - except AGFSClientError as e: - # SDK error already includes path, don't duplicate it - raise AGFSClientError(str(e)) - - def write_file( - self, - path: str, - data: Union[bytes, Iterator[bytes], BinaryIO], - append: bool = False, - ) -> Optional[str]: - """ - Write data to file in AGFS - - Args: - path: File path in AGFS - data: Data to write (bytes, iterator of bytes, or file-like object) - append: If True, append to file; if False, overwrite - - Returns: - Response message from server (if any) - - Raises: - AGFSClientError: If file cannot be written - """ - try: - if append: - # For append mode, we need to read existing content first - # This means we can't stream directly, need to collect all data - try: - existing = self.client.cat(path) - except AGFSClientError: - # File doesn't exist, just write new data - existing = b"" - - # Collect data if it's streaming - if hasattr(data, "__iter__") and not isinstance( - data, (bytes, bytearray) - ): - chunks = [existing] - for chunk in data: - chunks.append(chunk) - data = b"".join(chunks) - elif hasattr(data, "read"): - # File-like object - data = existing + data.read() - else: - data = existing + data - - # Write to AGFS - SDK now supports streaming data directly - # Use max_retries=0 for shell operations (fail fast) - response = self.client.write(path, data, max_retries=0) - return response - except AGFSClientError as e: - # SDK error already includes path, don't duplicate it - raise AGFSClientError(str(e)) - - def file_exists(self, path: str) -> bool: - """ - Check if file exists in AGFS - - Args: - path: File path in AGFS - - Returns: - True if file exists, False otherwise - """ - try: - self.client.stat(path) - return True - except AGFSClientError: - return False - - def is_directory(self, path: str) -> bool: - """ - Check if path is a directory - - Args: - path: Path in AGFS - - Returns: - True if path is a directory, False otherwise - """ - try: - info = self.client.stat(path) - # Check if it's a directory based on mode or isDir field - return info.get("isDir", False) - except AGFSClientError: - return False - - def list_directory(self, path: str): - """ - List directory contents - - Args: - path: Directory path in AGFS - - Returns: - List of file info dicts - - Raises: - AGFSClientError: If directory cannot be listed - """ - try: - return self.client.ls(path) - except AGFSClientError as e: - # SDK error already includes path, don't duplicate it - raise AGFSClientError(str(e)) - - def get_file_info(self, path: str): - """ - Get file/directory information - - Args: - path: File or directory path in AGFS - - Returns: - Dict containing file information (name, size, mode, modTime, isDir, etc.) - - Raises: - AGFSClientError: If file/directory does not exist - """ - try: - return self.client.stat(path) - except AGFSClientError as e: - # SDK error already includes path, don't duplicate it - raise AGFSClientError(str(e)) - - def touch_file(self, path: str) -> None: - """ - Touch a file (update timestamp by writing empty content) - - Args: - path: File path in AGFS - - Raises: - AGFSClientError: If file cannot be touched - """ - try: - self.client.touch(path) - except AGFSClientError as e: - # SDK error already includes path, don't duplicate it - raise AGFSClientError(str(e)) - - def get_error_message(self, error: Exception) -> str: - """ - Get user-friendly error message - - Args: - error: Exception object - - Returns: - Formatted error message - """ - if isinstance(error, AGFSClientError): - msg = str(error) - if "Connection refused" in msg: - return f"AGFS server not running at {self.server_url}" - return msg - return str(error) diff --git a/third_party/agfs/agfs-shell/agfs_shell/lexer.py b/third_party/agfs/agfs-shell/agfs_shell/lexer.py deleted file mode 100644 index c422b63cd..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/lexer.py +++ /dev/null @@ -1,333 +0,0 @@ -""" -Robust lexer for shell command parsing - -This module provides a unified lexer that handles: -- Quote tracking (single and double quotes) -- Escape sequences -- Comment detection -- Token splitting - -Replaces fragile manual character-by-character parsing throughout the codebase. -""" - -from typing import List, Tuple, Optional -from enum import Enum - - -class TokenType(Enum): - """Types of tokens the lexer can produce""" - WORD = "word" - PIPE = "pipe" - REDIRECT = "redirect" - COMMENT = "comment" - EOF = "eof" - - -class Token: - """A single lexical token""" - - def __init__(self, type: TokenType, value: str, position: int = 0): - self.type = type - self.value = value - self.position = position - - def __repr__(self): - return f"Token({self.type.value}, {repr(self.value)}, pos={self.position})" - - def __eq__(self, other): - if not isinstance(other, Token): - return False - return self.type == other.type and self.value == other.value - - -class ShellLexer: - """ - Robust lexer for shell commands - - Handles quotes, escapes, and special characters correctly. - """ - - def __init__(self, text: str): - """ - Initialize lexer with text to parse - - Args: - text: Shell command line to tokenize - """ - self.text = text - self.pos = 0 - self.length = len(text) - - def peek(self, offset: int = 0) -> Optional[str]: - """Look ahead at character without consuming it""" - pos = self.pos + offset - if pos < self.length: - return self.text[pos] - return None - - def advance(self) -> Optional[str]: - """Consume and return current character""" - if self.pos < self.length: - char = self.text[self.pos] - self.pos += 1 - return char - return None - - def skip_whitespace(self): - """Skip over whitespace characters""" - while self.peek() and self.peek() in ' \t': - self.advance() - - def read_quoted_string(self, quote_char: str) -> str: - """ - Read a quoted string, handling escapes - - Args: - quote_char: Quote character (' or ") - - Returns: - Content of quoted string (without quotes) - """ - result = [] - # Skip opening quote - self.advance() - - while True: - char = self.peek() - - if char is None: - # Unclosed quote - return what we have - break - - if char == '\\' and quote_char == '"': - # Escape sequence in double quotes - self.advance() - next_char = self.advance() - if next_char: - result.append(next_char) - elif char == quote_char: - # Closing quote - self.advance() - break - else: - result.append(char) - self.advance() - - return ''.join(result) - - def read_word(self) -> str: - """ - Read a word token, respecting quotes and escapes - - Returns: - Word content - """ - result = [] - - while True: - char = self.peek() - - if char is None: - break - - # Check for special characters that end a word - if char in ' \t\n|<>;&': - break - - # Handle quotes - if char == '"': - quoted = self.read_quoted_string('"') - result.append(quoted) - elif char == "'": - quoted = self.read_quoted_string("'") - result.append(quoted) - # Handle escapes - elif char == '\\': - self.advance() - next_char = self.advance() - if next_char: - result.append(next_char) - else: - result.append(char) - self.advance() - - return ''.join(result) - - def tokenize(self) -> List[Token]: - """ - Tokenize the entire input - - Returns: - List of tokens - """ - tokens = [] - - while self.pos < self.length: - self.skip_whitespace() - - if self.pos >= self.length: - break - - char = self.peek() - start_pos = self.pos - - # Check for comments - if char == '#': - # Read to end of line - comment = [] - while self.peek() and self.peek() != '\n': - comment.append(self.advance()) - tokens.append(Token(TokenType.COMMENT, ''.join(comment), start_pos)) - continue - - # Check for pipe - if char == '|': - self.advance() - tokens.append(Token(TokenType.PIPE, '|', start_pos)) - continue - - # Check for redirections - if char == '>': - redir = self.advance() - if self.peek() == '>': - redir += self.advance() - tokens.append(Token(TokenType.REDIRECT, redir, start_pos)) - continue - - if char == '<': - redir = self.advance() - if self.peek() == '<': - redir += self.advance() - tokens.append(Token(TokenType.REDIRECT, redir, start_pos)) - continue - - if char == '2': - if self.peek(1) == '>': - redir = self.advance() + self.advance() - if self.peek() == '>': - redir += self.advance() - tokens.append(Token(TokenType.REDIRECT, redir, start_pos)) - continue - - # Otherwise, read a word - word = self.read_word() - if word: - tokens.append(Token(TokenType.WORD, word, start_pos)) - - tokens.append(Token(TokenType.EOF, '', self.pos)) - return tokens - - -class QuoteTracker: - """ - Utility class to track quote state while parsing - - Use this when you need to manually parse but need to know if you're inside quotes. - """ - - def __init__(self): - self.in_single_quote = False - self.in_double_quote = False - self.escape_next = False - - def process_char(self, char: str): - """ - Update quote state based on character - - Args: - char: Current character being processed - """ - if self.escape_next: - self.escape_next = False - return - - if char == '\\': - self.escape_next = True - return - - if char == '"' and not self.in_single_quote: - self.in_double_quote = not self.in_double_quote - elif char == "'" and not self.in_double_quote: - self.in_single_quote = not self.in_single_quote - - def is_quoted(self) -> bool: - """Check if currently inside any type of quotes""" - return self.in_single_quote or self.in_double_quote - - def reset(self): - """Reset quote tracking state""" - self.in_single_quote = False - self.in_double_quote = False - self.escape_next = False - - -def strip_comments(line: str, comment_chars: str = '#') -> str: - """ - Strip comments from a line, respecting quotes - - Args: - line: Line to process - comment_chars: Characters that start comments (default: '#') - - Returns: - Line with comments removed - - Example: - >>> strip_comments('echo "test # not a comment" # real comment') - 'echo "test # not a comment" ' - """ - tracker = QuoteTracker() - result = [] - - for i, char in enumerate(line): - tracker.process_char(char) - - # Check if this starts a comment (when not quoted) - if char in comment_chars and not tracker.is_quoted(): - break - - result.append(char) - - return ''.join(result).rstrip() - - -def split_respecting_quotes(text: str, delimiter: str) -> List[str]: - """ - Split text by delimiter, but only when not inside quotes - - This is a utility function that uses QuoteTracker. - For more complex parsing, use ShellLexer instead. - - Args: - text: Text to split - delimiter: Delimiter to split on - - Returns: - List of parts - - Example: - >>> split_respecting_quotes('echo "a | b" | wc', '|') - ['echo "a | b" ', ' wc'] - """ - tracker = QuoteTracker() - parts = [] - current = [] - i = 0 - - while i < len(text): - char = text[i] - tracker.process_char(char) - - # Check for delimiter when not quoted - if not tracker.is_quoted() and text[i:i+len(delimiter)] == delimiter: - parts.append(''.join(current)) - current = [] - i += len(delimiter) - else: - current.append(char) - i += 1 - - if current: - parts.append(''.join(current)) - - return parts diff --git a/third_party/agfs/agfs-shell/agfs_shell/parser.py b/third_party/agfs/agfs-shell/agfs_shell/parser.py deleted file mode 100644 index d41b0b10c..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/parser.py +++ /dev/null @@ -1,356 +0,0 @@ -"""Shell command parser for pipeline syntax""" - -import shlex -import re -from typing import List, Tuple, Dict, Optional - - -class Redirection: - """Represents a redirection operation""" - def __init__(self, operator: str, target: str, fd: int = None): - self.operator = operator # '<', '>', '>>', '2>', '2>>', '&>', etc. - self.target = target # filename - self.fd = fd # file descriptor (0=stdin, 1=stdout, 2=stderr) - - -class CommandParser: - """Parse shell command strings into pipeline components""" - - @staticmethod - def _split_respecting_quotes(text: str, delimiter: str) -> List[str]: - """ - Split a string by delimiter, but only when not inside quotes - - Args: - text: String to split - delimiter: Delimiter to split on (e.g., '|', '>') - - Returns: - List of parts split by unquoted delimiters - - Example: - >>> _split_respecting_quotes('echo "a | b" | wc', '|') - ['echo "a | b" ', ' wc'] - """ - parts = [] - current_part = [] - in_single_quote = False - in_double_quote = False - escape_next = False - i = 0 - - while i < len(text): - char = text[i] - - # Handle escape sequences - if escape_next: - current_part.append(char) - escape_next = False - i += 1 - continue - - if char == '\\': - current_part.append(char) - escape_next = True - i += 1 - continue - - # Track quote state - if char == '"' and not in_single_quote: - in_double_quote = not in_double_quote - current_part.append(char) - elif char == "'" and not in_double_quote: - in_single_quote = not in_single_quote - current_part.append(char) - # Check for delimiter when not in quotes - elif not in_single_quote and not in_double_quote: - # Check if we match the delimiter - if text[i:i+len(delimiter)] == delimiter: - # Found delimiter outside quotes - parts.append(''.join(current_part)) - current_part = [] - i += len(delimiter) - continue - else: - current_part.append(char) - else: - current_part.append(char) - - i += 1 - - # Add the last part - if current_part: - parts.append(''.join(current_part)) - - return parts - - @staticmethod - def _find_redirections_respecting_quotes(command_line: str) -> Tuple[str, Dict[str, str]]: - """ - Find redirection operators in command line, respecting quotes - - Args: - command_line: Command line with possible redirections - - Returns: - Tuple of (cleaned command, redirection dict) - """ - redirections = {} - - # Parse character by character, tracking quote state - result = [] - i = 0 - in_single_quote = False - in_double_quote = False - escape_next = False - - while i < len(command_line): - char = command_line[i] - - # Handle escape sequences - if escape_next: - result.append(char) - escape_next = False - i += 1 - continue - - if char == '\\': - result.append(char) - escape_next = True - i += 1 - continue - - # Track quote state - if char == '"' and not in_single_quote: - in_double_quote = not in_double_quote - result.append(char) - i += 1 - elif char == "'" and not in_double_quote: - in_single_quote = not in_single_quote - result.append(char) - i += 1 - # Look for redirections when not in quotes - elif not in_single_quote and not in_double_quote: - # Try to match redirection operators (longest first) - matched = False - - # Check for heredoc << (must be before <) - if i < len(command_line) - 1 and command_line[i:i+2] == '<<': - # Find the delimiter - i += 2 - # Skip whitespace - while i < len(command_line) and command_line[i] in ' \t': - i += 1 - # Extract delimiter - delimiter = [] - while i < len(command_line) and command_line[i] not in ' \t\n': - delimiter.append(command_line[i]) - i += 1 - if delimiter: - redirections['heredoc_delimiter'] = ''.join(delimiter) - matched = True - - # Check for 2>> (append stderr) - elif i < len(command_line) - 2 and command_line[i:i+3] == '2>>': - i += 3 - filename = CommandParser._extract_filename(command_line, i) - if filename: - redirections['stderr'] = filename[0] - redirections['stderr_mode'] = 'append' - i = filename[1] - matched = True - - # Check for 2> (stderr) - elif i < len(command_line) - 1 and command_line[i:i+2] == '2>': - i += 2 - filename = CommandParser._extract_filename(command_line, i) - if filename: - redirections['stderr'] = filename[0] - redirections['stderr_mode'] = 'write' - i = filename[1] - matched = True - - # Check for >> (append stdout) - elif i < len(command_line) - 1 and command_line[i:i+2] == '>>': - i += 2 - filename = CommandParser._extract_filename(command_line, i) - if filename: - redirections['stdout'] = filename[0] - redirections['stdout_mode'] = 'append' - i = filename[1] - matched = True - - # Check for > (stdout) - elif command_line[i] == '>': - i += 1 - filename = CommandParser._extract_filename(command_line, i) - if filename: - redirections['stdout'] = filename[0] - redirections['stdout_mode'] = 'write' - i = filename[1] - matched = True - - # Check for < (stdin) - elif command_line[i] == '<': - i += 1 - filename = CommandParser._extract_filename(command_line, i) - if filename: - redirections['stdin'] = filename[0] - i = filename[1] - matched = True - - if not matched: - result.append(char) - i += 1 - else: - result.append(char) - i += 1 - - return ''.join(result).strip(), redirections - - @staticmethod - def _extract_filename(command_line: str, start_pos: int) -> Optional[Tuple[str, int]]: - """ - Extract filename after a redirection operator - - Args: - command_line: Full command line - start_pos: Position to start looking for filename - - Returns: - Tuple of (filename, new_position) or None - """ - i = start_pos - - # Skip whitespace - while i < len(command_line) and command_line[i] in ' \t': - i += 1 - - if i >= len(command_line): - return None - - filename = [] - in_quotes = None - - # Check if filename is quoted - if command_line[i] in ('"', "'"): - in_quotes = command_line[i] - i += 1 - # Read until closing quote - while i < len(command_line): - if command_line[i] == in_quotes: - i += 1 - break - filename.append(command_line[i]) - i += 1 - else: - # Read until whitespace or special character - while i < len(command_line) and command_line[i] not in ' \t\n|<>;&': - filename.append(command_line[i]) - i += 1 - - if filename: - return (''.join(filename), i) - return None - - @staticmethod - def parse_command_line(command_line: str) -> Tuple[List[Tuple[str, List[str]]], Dict]: - """ - Parse a complete command line with pipelines and redirections - Now with quote-aware parsing! - - Args: - command_line: Full command line string - - Returns: - Tuple of (pipeline_commands, global_redirections) - - Example: - >>> parse_command_line('echo "a | b" | wc > out.txt') - ([('echo', ['a | b']), ('wc', [])], {'stdout': 'out.txt', 'stdout_mode': 'write'}) - """ - # First, extract global redirections (those at the end of the pipeline) - # Use the new quote-aware redirection parser - command_line, redirections = CommandParser.parse_redirection(command_line) - - # Then parse the pipeline - commands = CommandParser.parse_pipeline(command_line) - - return commands, redirections - - @staticmethod - def parse_pipeline(command_line: str) -> List[Tuple[str, List[str]]]: - """ - Parse a command line into pipeline components - Now respects quotes! Pipes inside quotes are preserved. - - Args: - command_line: Command line string (e.g., "cat file.txt | grep pattern | wc -l") - - Returns: - List of (command, args) tuples - - Example: - >>> parser.parse_pipeline('echo "This | that" | wc') - [('echo', ['This | that']), ('wc', [])] - """ - if not command_line.strip(): - return [] - - # Use quote-aware splitting instead of simple split('|') - pipeline_parts = CommandParser._split_respecting_quotes(command_line, '|') - - commands = [] - for part in pipeline_parts: - part = part.strip() - if not part: - continue - - # Use shlex to properly handle quoted strings - try: - tokens = shlex.split(part) - except ValueError as e: - # If shlex fails (unmatched quotes), fall back to simple split - tokens = part.split() - - if tokens: - command = tokens[0] - args = tokens[1:] if len(tokens) > 1 else [] - commands.append((command, args)) - - return commands - - @staticmethod - def parse_redirection(command_line: str) -> Tuple[str, Dict[str, str]]: - """ - Parse redirection operators - Now respects quotes! Redirections inside quotes are preserved. - - Args: - command_line: Command line with possible redirections - - Returns: - Tuple of (cleaned command, redirection dict) - Redirection dict keys: 'stdin', 'stdout', 'stderr', 'stdout_mode', 'heredoc_delimiter' - - Example: - >>> parse_redirection('echo "Look at this arrow ->" > file.txt') - ('echo "Look at this arrow ->"', {'stdout': 'file.txt', 'stdout_mode': 'write'}) - """ - # Use the new quote-aware redirection finder - return CommandParser._find_redirections_respecting_quotes(command_line) - - @staticmethod - def quote_arg(arg: str) -> str: - """Quote an argument if it contains spaces or special characters""" - if ' ' in arg or any(c in arg for c in '|&;<>()$`\\"\''): - return shlex.quote(arg) - return arg - - @staticmethod - def unquote_arg(arg: str) -> str: - """Remove quotes from an argument""" - if (arg.startswith('"') and arg.endswith('"')) or \ - (arg.startswith("'") and arg.endswith("'")): - return arg[1:-1] - return arg diff --git a/third_party/agfs/agfs-shell/agfs_shell/pipeline.py b/third_party/agfs/agfs-shell/agfs_shell/pipeline.py deleted file mode 100644 index 8e16e6646..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/pipeline.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Pipeline class for chaining processes together with true streaming""" - -import threading -import queue -import io -from typing import List, Union -from .process import Process -from .streams import InputStream, OutputStream, ErrorStream -from .control_flow import ControlFlowException - - -class StreamingPipeline: - """ - True streaming pipeline implementation - - Processes run in parallel threads with streaming I/O between them. - This prevents memory exhaustion on large data sets. - """ - - def __init__(self, processes: List[Process]): - """ - Initialize a streaming pipeline - - Args: - processes: List of Process objects to chain together - """ - self.processes = processes - self.exit_codes = [] - self.threads = [] - self.pipes = [] # Queue-based pipes between processes - - def execute(self) -> int: - """ - Execute the entire pipeline with true streaming - - All processes run in parallel threads, connected by queues. - Data flows through the pipeline in chunks without full buffering. - - Returns: - Exit code of the last process - """ - if not self.processes: - return 0 - - # Special case: single process (no piping needed) - if len(self.processes) == 1: - return self.processes[0].execute() - - # Create pipes (queues) between processes - self.pipes = [queue.Queue(maxsize=10) for _ in range(len(self.processes) - 1)] - self.exit_codes = [None] * len(self.processes) - - # Create wrapper streams that read from/write to queues - for i, process in enumerate(self.processes): - # Set up stdin: read from previous process's queue - if i > 0: - process.stdin = StreamingInputStream(self.pipes[i - 1]) - - # Set up stdout: write to next process's queue - if i < len(self.processes) - 1: - process.stdout = StreamingOutputStream(self.pipes[i]) - - # Start all processes in parallel threads - for i, process in enumerate(self.processes): - thread = threading.Thread( - target=self._execute_process, - args=(i, process), - name=f"Process-{i}-{process.command}" - ) - thread.start() - self.threads.append(thread) - - # Wait for all processes to complete - for thread in self.threads: - thread.join() - - # Return exit code of last process - return self.exit_codes[-1] if self.exit_codes else 0 - - def _execute_process(self, index: int, process: Process): - """ - Execute a single process in a thread - - Args: - index: Process index in the pipeline - process: Process object to execute - """ - try: - exit_code = process.execute() - self.exit_codes[index] = exit_code - except KeyboardInterrupt: - # Let KeyboardInterrupt propagate for proper Ctrl-C handling - raise - except ControlFlowException: - # Let control flow exceptions propagate - raise - except Exception as e: - process.stderr.write(f"Pipeline error: {e}\n") - self.exit_codes[index] = 1 - finally: - # Signal EOF to next process by properly closing stdout - # This ensures any buffered data is flushed before EOF - if index < len(self.processes) - 1: - if isinstance(process.stdout, StreamingOutputStream): - process.stdout.close() # flush remaining buffer and send EOF - else: - self.pipes[index].put(None) # EOF marker - - -class StreamingInputStream(InputStream): - """Input stream that reads from a queue in chunks""" - - def __init__(self, pipe: queue.Queue): - super().__init__(None) - self.pipe = pipe - self._buffer = io.BytesIO() - self._eof = False - - def read(self, size: int = -1) -> bytes: - """Read from the queue-based pipe""" - if size == -1: - # Read all available data - chunks = [] - while not self._eof: - chunk = self.pipe.get() - if chunk is None: # EOF - self._eof = True - break - chunks.append(chunk) - return b''.join(chunks) - else: - # Read specific number of bytes - data = b'' - while len(data) < size and not self._eof: - # Check if we have buffered data - buffered = self._buffer.read(size - len(data)) - if buffered: - data += buffered - if len(data) >= size: - break - - # Get more data from queue - chunk = self.pipe.get() - if chunk is None: # EOF - self._eof = True - break - - # Put in buffer - self._buffer = io.BytesIO(chunk) - - return data - - def readline(self) -> bytes: - """Read a line from the pipe""" - line = [] - while not self._eof: - byte = self.read(1) - if not byte: - break - line.append(byte) - if byte == b'\n': - break - return b''.join(line) - - def readlines(self) -> list: - """Read all lines from the pipe""" - lines = [] - while not self._eof: - line = self.readline() - if not line: - break - lines.append(line) - return lines - - -class StreamingOutputStream(OutputStream): - """Output stream that writes to a queue in chunks""" - - def __init__(self, pipe: queue.Queue, chunk_size: int = 8192): - super().__init__(None) - self.pipe = pipe - self.chunk_size = chunk_size - self._buffer = io.BytesIO() - - def write(self, data: Union[bytes, str]) -> int: - """Write data to the queue-based pipe""" - if isinstance(data, str): - data = data.encode('utf-8') - - # Write to buffer - self._buffer.write(data) - - # Flush chunks if buffer is large enough - buffer_size = self._buffer.tell() - if buffer_size >= self.chunk_size: - self.flush() - - return len(data) - - def flush(self): - """Flush buffered data to the queue""" - self._buffer.seek(0) - data = self._buffer.read() - if data: - self.pipe.put(data) - self._buffer = io.BytesIO() - - def close(self): - """Close the stream and flush remaining data""" - self.flush() - self.pipe.put(None) # EOF marker - - -class Pipeline: - """ - Hybrid pipeline implementation - - Uses streaming for pipelines that may have large data. - Falls back to buffered execution for compatibility. - """ - - def __init__(self, processes: List[Process]): - """ - Initialize a pipeline - - Args: - processes: List of Process objects to chain together - """ - self.processes = processes - self.exit_codes = [] - self.use_streaming = len(processes) > 1 # Use streaming for multi-process pipelines - - def execute(self) -> int: - """ - Execute the entire pipeline - - Automatically chooses between streaming and buffered execution. - - Returns: - Exit code of the last process - """ - if not self.processes: - return 0 - - # Use streaming pipeline for multi-process pipelines - if self.use_streaming: - streaming_pipeline = StreamingPipeline(self.processes) - exit_code = streaming_pipeline.execute() - self.exit_codes = streaming_pipeline.exit_codes - return exit_code - - # Single process: execute directly (buffered) - if not self.processes: - return 0 - - self.exit_codes = [] - - # Execute processes in sequence, piping output to next input - for i, process in enumerate(self.processes): - # If this is not the first process, connect previous stdout to this stdin - if i > 0: - prev_process = self.processes[i - 1] - prev_output = prev_process.get_stdout() - process.stdin = InputStream.from_bytes(prev_output) - - # Execute the process - exit_code = process.execute() - self.exit_codes.append(exit_code) - - # Return exit code of last process - return self.exit_codes[-1] if self.exit_codes else 0 - - def get_stdout(self) -> bytes: - """Get final stdout from the last process""" - if not self.processes: - return b'' - return self.processes[-1].get_stdout() - - def get_stderr(self) -> bytes: - """Get combined stderr from all processes""" - stderr_data = b'' - for process in self.processes: - stderr_data += process.get_stderr() - return stderr_data - - def get_exit_code(self) -> int: - """Get exit code of the last process""" - return self.exit_codes[-1] if self.exit_codes else 0 - - def __repr__(self): - pipeline_str = ' | '.join(str(p) for p in self.processes) - return f"Pipeline({pipeline_str})" diff --git a/third_party/agfs/agfs-shell/agfs_shell/process.py b/third_party/agfs/agfs-shell/agfs_shell/process.py deleted file mode 100644 index 72de73745..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/process.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Process class for command execution in pipelines""" - -from typing import List, Optional, Callable, TYPE_CHECKING - -if TYPE_CHECKING: - from .filesystem import AGFSFileSystem - -from .streams import InputStream, OutputStream, ErrorStream -from .control_flow import ControlFlowException - - -class Process: - """Represents a single process/command in a pipeline""" - - def __init__( - self, - command: str, - args: List[str], - stdin: Optional[InputStream] = None, - stdout: Optional[OutputStream] = None, - stderr: Optional[ErrorStream] = None, - executor: Optional[Callable] = None, - filesystem: Optional['AGFSFileSystem'] = None, - env: Optional[dict] = None - ): - """ - Initialize a process - - Args: - command: Command name - args: Command arguments - stdin: Input stream - stdout: Output stream - stderr: Error stream - executor: Callable that executes the command - filesystem: AGFS file system instance for file operations - env: Environment variables dictionary - """ - self.command = command - self.args = args - self.stdin = stdin or InputStream.from_bytes(b'') - self.stdout = stdout or OutputStream.to_buffer() - self.stderr = stderr or ErrorStream.to_buffer() - self.executor = executor - self.filesystem = filesystem - self.env = env or {} - self.exit_code = 0 - - def execute(self) -> int: - """ - Execute the process - - Returns: - Exit code (0 for success, non-zero for error) - """ - if self.executor is None: - self.stderr.write(f"Error: No such command '{self.command}'\n") - self.exit_code = 127 - return self.exit_code - - try: - # Execute the command - self.exit_code = self.executor(self) - except KeyboardInterrupt: - # Let KeyboardInterrupt propagate for proper Ctrl-C handling - raise - except ControlFlowException: - # Let control flow exceptions (break, continue, return) propagate - raise - except Exception as e: - self.stderr.write(f"Error executing '{self.command}': {str(e)}\n") - self.exit_code = 1 - - # Flush all streams - self.stdout.flush() - self.stderr.flush() - - return self.exit_code - - def get_stdout(self) -> bytes: - """Get stdout contents""" - return self.stdout.get_value() - - def get_stderr(self) -> bytes: - """Get stderr contents""" - return self.stderr.get_value() - - def __repr__(self): - args_str = ' '.join(self.args) if self.args else '' - return f"Process({self.command} {args_str})" diff --git a/third_party/agfs/agfs-shell/agfs_shell/shell.py b/third_party/agfs/agfs-shell/agfs_shell/shell.py deleted file mode 100644 index 47d23881d..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/shell.py +++ /dev/null @@ -1,2255 +0,0 @@ -"""Shell implementation with REPL and command execution""" - -import sys -import os -from typing import Optional, List -from rich.console import Console -from .parser import CommandParser -from .pipeline import Pipeline -from .process import Process -from .streams import InputStream, OutputStream, ErrorStream -from .builtins import get_builtin -from .filesystem import AGFSFileSystem -from .command_decorators import CommandMetadata -from pyagfs import AGFSClientError -from . import __version__ -from .exit_codes import ( - EXIT_CODE_CONTINUE, - EXIT_CODE_BREAK, - EXIT_CODE_FOR_LOOP_NEEDED, - EXIT_CODE_WHILE_LOOP_NEEDED, - EXIT_CODE_IF_STATEMENT_NEEDED, - EXIT_CODE_HEREDOC_NEEDED, - EXIT_CODE_FUNCTION_DEF_NEEDED, - EXIT_CODE_RETURN -) -from .control_flow import BreakException, ContinueException, ReturnException -from .control_parser import ControlParser -from .executor import ShellExecutor -from .expression import ExpressionExpander - - -class Shell: - """Simple shell with pipeline support""" - - def __init__(self, server_url: str = "http://localhost:8080", timeout: int = 30): - self.parser = CommandParser() - self.running = True - self.filesystem = AGFSFileSystem(server_url, timeout=timeout) - self.server_url = server_url - self.cwd = '/' # Current working directory - self.console = Console(highlight=False) # Rich console for output - self.multiline_buffer = [] # Buffer for multiline input - self.env = {} # Environment variables - self.env['?'] = '0' # Last command exit code - - # Set default history file location - import os - home = os.path.expanduser("~") - self.env['HISTFILE'] = os.path.join(home, ".agfs_shell_history") - - self.interactive = False # Flag to indicate if running in interactive REPL mode - - # Function definitions: {name: {'params': [...], 'body': [...]}} - self.functions = {} - - # Variable scope stack for local variables - # Each entry is a dict of local variables for that scope - self.local_scopes = [] - - # Control flow components - self.control_parser = ControlParser(self) - self.executor = ShellExecutor(self) - - # Expression expander (unified variable/arithmetic/command substitution) - self.expression_expander = ExpressionExpander(self) - - def _execute_command_substitution(self, command: str) -> str: - """ - Execute a command and return its output as a string - Used for command substitution: $(command) or `command` - """ - from .streams import OutputStream, InputStream, ErrorStream - from .builtins import get_builtin - - # Parse and execute the command, capturing stdout - try: - # Expand variables AND arithmetic, but handle command substitution carefully - # We need full expansion for the command - command = self._expand_variables(command) - - # Parse the command - commands, redirections = self.parser.parse_command_line(command) - if not commands: - return '' - - # Check if this is a user-defined function call (single command only) - if len(commands) == 1: - cmd, args = commands[0] - if cmd in self.functions: - # Execute the function and capture all its output - # We need to capture at the stream level, not sys.stdout - import io - - # Create a buffer to capture output - output_buffer = io.BytesIO() - - # Save real stdout buffer - import sys - old_stdout_buffer = sys.stdout.buffer if hasattr(sys.stdout, 'buffer') else None - - # Create a wrapper that has .buffer attribute - class StdoutWrapper: - def __init__(self, buffer): - self._buffer = buffer - @property - def buffer(self): - return self._buffer - def write(self, s): - if isinstance(s, str): - self._buffer.write(s.encode('utf-8')) - else: - self._buffer.write(s) - def flush(self): - pass - - # Temporarily replace sys.stdout - old_stdout = sys.stdout - sys.stdout = StdoutWrapper(output_buffer) - - try: - # Execute the function - exit_code = self.execute_function(cmd, args) - - # Get all captured output - output = output_buffer.getvalue().decode('utf-8') - # Remove trailing newline if present - if output.endswith('\n'): - output = output[:-1] - return output - - finally: - # Restore stdout - sys.stdout = old_stdout - - # Build processes for each command (simplified, no redirections) - processes = [] - for i, (cmd, args) in enumerate(commands): - executor = get_builtin(cmd) - - # Resolve paths for file commands (using metadata instead of hardcoded list) - if CommandMetadata.needs_path_resolution(cmd): - resolved_args = [] - skip_next = False - for j, arg in enumerate(args): - # Skip if this is a flag value (e.g., the "2" in "-n 2") - if skip_next: - resolved_args.append(arg) - skip_next = False - continue - - # Skip flags (starting with -) - if arg.startswith('-'): - resolved_args.append(arg) - # Check if this flag takes a value (e.g., -n, -L, -d, -f) - if arg in ['-n', '-L', '-d', '-f', '-t', '-c'] and j + 1 < len(args): - skip_next = True - continue - - # Skip pure numbers (they're likely option values, not paths) - try: - float(arg) - resolved_args.append(arg) - continue - except ValueError: - pass - - # Resolve path - resolved_args.append(self.resolve_path(arg)) - args = resolved_args - - # Create streams - always capture to buffer - stdin = InputStream.from_bytes(b'') - stdout = OutputStream.to_buffer() - stderr = ErrorStream.to_buffer() - - # Create process - process = Process( - command=cmd, - args=args, - stdin=stdin, - stdout=stdout, - stderr=stderr, - executor=executor, - filesystem=self.filesystem, - env=self.env - ) - process.cwd = self.cwd - processes.append(process) - - # Execute pipeline sequentially, like Pipeline class - for i, process in enumerate(processes): - # If this is not the first process, connect previous stdout to this stdin - if i > 0: - prev_process = processes[i - 1] - prev_output = prev_process.get_stdout() - process.stdin = InputStream.from_bytes(prev_output) - - # Execute the process - process.execute() - - # Get output from last process - output = processes[-1].get_stdout() - output_str = output.decode('utf-8', errors='replace') - # Only remove trailing newline (not all whitespace) - if output_str.endswith('\n'): - output_str = output_str[:-1] - return output_str - except Exception as e: - return '' - - def _strip_comment(self, line: str) -> str: - """ - Remove comments from a command line - - Lines starting with # are treated as full comments - - Inline comments (# after command) are removed - - Comment markers inside quotes are preserved - - Uses the robust lexer module for consistent parsing. - - Args: - line: Command line string - - Returns: - Line with comments removed - """ - from .lexer import strip_comments - - # Empty line check - if not line.lstrip(): - return '' - - # Strip # comments using lexer (respects quotes) - return strip_comments(line, comment_chars='#') - - def _get_variable(self, var_name: str) -> str: - """ - Get variable value, checking local scopes first, then global env - - Args: - var_name: Variable name - - Returns: - Variable value or empty string if not found - """ - # Check if we're in a function and have a local variable - if self.env.get('_function_depth'): - local_key = f'_local_{var_name}' - if local_key in self.env: - return self.env[local_key] - - # Check local scopes from innermost to outermost - for scope in reversed(self.local_scopes): - if var_name in scope: - return scope[var_name] - - # Fall back to global env - return self.env.get(var_name, '') - - def _set_variable(self, var_name: str, value: str, local: bool = False): - """ - Set variable value - - Args: - var_name: Variable name - value: Variable value - local: If True, set in current local scope; otherwise set in global env - """ - if local and self.local_scopes: - # Set in current local scope - self.local_scopes[-1][var_name] = value - # Also set in env with _local_ prefix for compatibility - self.env[f'_local_{var_name}'] = value - elif self.env.get('_function_depth') and f'_local_{var_name}' in self.env: - # We're in a function and this variable was declared local - # Update the local variable, not the global one - self.env[f'_local_{var_name}'] = value - else: - # Set in global env - self.env[var_name] = value - - def _expand_basic_variables(self, text: str) -> str: - """ - Core variable expansion logic (shared by all expansion methods) - - Expands: - - Special variables: $?, $#, $@, $0 - - Braced variables: ${VAR} - - Positional parameters: $1, $2, ... - - Simple variables: $VAR - - Does NOT expand: - - Arithmetic: $((expr)) - - Command substitution: $(cmd), `cmd` - - Args: - text: Text containing variable references - - Returns: - Text with variables expanded - """ - import re - - # First, expand special variables (in specific order to avoid conflicts) - text = text.replace('$?', self._get_variable('?')) - text = text.replace('$#', self._get_variable('#')) - text = text.replace('$@', self._get_variable('@')) - text = text.replace('$0', self._get_variable('0')) - - # Expand ${VAR} - def replace_braced(match): - var_name = match.group(1) - return self._get_variable(var_name) - - text = re.sub(r'\$\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}', replace_braced, text) - - # Expand $1, $2, etc. - def replace_positional(match): - var_name = match.group(1) - return self._get_variable(var_name) - - text = re.sub(r'\$(\d+)', replace_positional, text) - - # Expand $VAR - def replace_simple(match): - var_name = match.group(1) - return self._get_variable(var_name) - - text = re.sub(r'\$([A-Za-z_][A-Za-z0-9_]*)', replace_simple, text) - - return text - - def _expand_variables_without_command_sub(self, text: str) -> str: - """ - Expand environment variables but NOT command substitutions - Used in command substitution to avoid infinite recursion - - This is now a thin wrapper around _expand_basic_variables() - """ - return self._expand_basic_variables(text) - - def _safe_eval_arithmetic(self, expr: str) -> int: - """ - Safely evaluate an arithmetic expression without using eval() - - Supports: +, -, *, /, %, ** (power), and parentheses - Only allows integers and these operators - no function calls or imports - - Args: - expr: Arithmetic expression string (e.g., "5 + 3 * 2") - - Returns: - Integer result of evaluation - """ - import ast - import operator - - # Map of allowed operators - ALLOWED_OPS = { - ast.Add: operator.add, - ast.Sub: operator.sub, - ast.Mult: operator.mul, - ast.FloorDiv: operator.floordiv, # // operator - ast.Div: operator.truediv, # / operator - ast.Mod: operator.mod, - ast.Pow: operator.pow, - ast.USub: operator.neg, # Unary minus - ast.UAdd: operator.pos, # Unary plus - } - - def eval_node(node): - """Recursively evaluate AST nodes""" - if isinstance(node, ast.Constant): - # Python 3.8+ uses ast.Constant for numbers - if isinstance(node.value, (int, float)): - return node.value - else: - raise ValueError(f"Only numeric constants allowed, got {type(node.value)}") - elif hasattr(ast, 'Num') and isinstance(node, ast.Num): - # Python 3.7 and earlier use ast.Num (removed in Python 3.12) - return node.n - elif isinstance(node, ast.BinOp): - # Binary operation (e.g., 5 + 3) - if type(node.op) not in ALLOWED_OPS: - raise ValueError(f"Operator {type(node.op).__name__} not allowed") - left = eval_node(node.left) - right = eval_node(node.right) - return ALLOWED_OPS[type(node.op)](left, right) - elif isinstance(node, ast.UnaryOp): - # Unary operation (e.g., -5) - if type(node.op) not in ALLOWED_OPS: - raise ValueError(f"Operator {type(node.op).__name__} not allowed") - operand = eval_node(node.operand) - return ALLOWED_OPS[type(node.op)](operand) - else: - raise ValueError(f"Node type {type(node).__name__} not allowed") - - try: - # Strip whitespace before parsing - expr = expr.strip() - - # Parse the expression into an AST - tree = ast.parse(expr, mode='eval') - - # Evaluate the AST safely - result = eval_node(tree.body) - - # Return as integer (bash arithmetic uses integers) - return int(result) - except (SyntaxError, ValueError, ZeroDivisionError) as e: - # If evaluation fails, return 0 (bash behavior) - return 0 - except Exception: - # Catch any unexpected errors and return 0 - return 0 - - def _expand_variables(self, text: str) -> str: - """ - Expand ALL variable types and command substitutions - - Uses the new ExpressionExpander for unified handling of: - - Special variables: $?, $#, $@, $0 - - Simple variables: $VAR - - Braced variables: ${VAR}, ${VAR:-default}, ${VAR#pattern}, etc. - - Positional parameters: $1, $2, ... - - Arithmetic expressions: $((expr)) - - Command substitution: $(command), `command` - - Returns: - Text with all expansions applied - """ - return self.expression_expander.expand(text) - - def _expand_variables_legacy(self, text: str) -> str: - """ - Legacy implementation of variable expansion. - Kept for reference and fallback if needed. - """ - import re - - # Step 1: Expand command substitutions FIRST: $(command) and `command` - # This must be done BEFORE arithmetic to allow $(cmd) inside $((arithmetic)) - def replace_command_subst(command): - """Execute a command substitution and return its output""" - return self._execute_command_substitution(command) - - def find_innermost_command_subst(text, start_pos=0): - """ - Find the position of the innermost $(command) substitution. - Returns (start, end, command) or None if no substitution found. - """ - i = start_pos - while i < len(text) - 1: - if text[i:i+2] == '$(': - # Check if this is $(( - if i < len(text) - 2 and text[i:i+3] == '$((': - i += 1 - continue - - # Found a $( - scan to find matching ) - start = i - i += 2 - depth = 1 - cmd_start = i - - in_single_quote = False - in_double_quote = False - escape_next = False - has_nested = False - - while i < len(text) and depth > 0: - char = text[i] - - if escape_next: - escape_next = False - i += 1 - continue - - if char == '\\': - escape_next = True - i += 1 - continue - - if char == '"' and not in_single_quote: - in_double_quote = not in_double_quote - elif char == "'" and not in_double_quote: - in_single_quote = not in_single_quote - elif not in_single_quote and not in_double_quote: - # Check for nested $( - if i < len(text) - 1 and text[i:i+2] == '$(': - if i >= len(text) - 2 or text[i:i+3] != '$((': - has_nested = True - - if char == '(': - depth += 1 - elif char == ')': - depth -= 1 - - i += 1 - - if depth == 0: - command = text[cmd_start:i-1] - - # If this has nested substitutions, recurse to find the innermost - if has_nested: - nested_result = find_innermost_command_subst(text, cmd_start) - if nested_result: - return nested_result - - # This is innermost (no nested substitutions) - return (start, i, command) - else: - i += 1 - - return None - - def find_and_replace_command_subst(text): - """ - Find and replace $(command) patterns, processing from innermost to outermost - """ - max_iterations = 10 - for iteration in range(max_iterations): - result = find_innermost_command_subst(text) - - if result is None: - # No more substitutions - break - - start, end, command = result - replacement = replace_command_subst(command) - text = text[:start] + replacement + text[end:] - - return text - - text = find_and_replace_command_subst(text) - - # Process `...` command substitution (backticks) - def replace_backtick_subst(match): - command = match.group(1) - return self._execute_command_substitution(command) - - text = re.sub(r'`([^`]+)`', replace_backtick_subst, text) - - # Step 2: Expand arithmetic expressions $((expr)) - # This is done AFTER command substitution to allow $(cmd) inside arithmetic - def replace_arithmetic(match): - expr = match.group(1) - try: - # Expand variables in the expression - # In bash arithmetic, variables can be used with or without $ - # We need to expand both $VAR and VAR - expanded_expr = expr - - # First, expand ${VAR} and ${N} (braced form) - including positional params - for var_match in re.finditer(r'\$\{([A-Za-z_][A-Za-z0-9_]*|\d+)\}', expr): - var_name = var_match.group(1) - var_value = self._get_variable(var_name) or '0' - try: - int(var_value) - except ValueError: - var_value = '0' - expanded_expr = expanded_expr.replace(f'${{{var_name}}}', var_value) - - # Then expand $VAR and $N (non-braced form) - for var_match in re.finditer(r'\$([A-Za-z_][A-Za-z0-9_]*|\d+)', expanded_expr): - var_name = var_match.group(1) - var_value = self._get_variable(var_name) or '0' - # Try to convert to int, default to 0 if not numeric - try: - int(var_value) - except ValueError: - var_value = '0' - expanded_expr = expanded_expr.replace(f'${var_name}', var_value) - - # Then, expand VAR (without dollar sign) - # We need to be careful not to replace keywords like 'and', 'or', 'not' - # and not to replace numbers - for var_match in re.finditer(r'\b([A-Za-z_][A-Za-z0-9_]*)\b', expanded_expr): - var_name = var_match.group(1) - # Skip Python keywords - if var_name in ['and', 'or', 'not', 'in', 'is']: - continue - # Check if variable exists (in local or global scope) - var_value = self._get_variable(var_name) - if var_value: - # Try to convert to int, default to 0 if not numeric - try: - int(var_value) - except ValueError: - var_value = '0' - expanded_expr = expanded_expr.replace(var_name, var_value) - - # Safely evaluate the arithmetic expression using AST parser - # This replaces the dangerous eval() call with a secure alternative - result = self._safe_eval_arithmetic(expanded_expr) - return str(result) - except Exception as e: - # If evaluation fails, return 0 - return '0' - - # Use a more sophisticated pattern to handle nested parentheses - # Match $((anything)) where we need to count parentheses properly - def find_and_replace_arithmetic(text): - result = [] - i = 0 - while i < len(text): - # Look for $(( - if i < len(text) - 2 and text[i:i+3] == '$((': - # Found start of arithmetic expression - start = i - i += 3 - depth = 2 # We've seen $(( which is 2 open parens - expr_start = i - - # Find the matching )) - while i < len(text) and depth > 0: - if text[i] == '(': - depth += 1 - elif text[i] == ')': - depth -= 1 - i += 1 - - if depth == 0: - # Found matching )) - expr = text[expr_start:i-2] # -2 to exclude the )) - # Create a match object-like thing - class FakeMatch: - def __init__(self, expr): - self.expr = expr - def group(self, n): - return self.expr - replacement = replace_arithmetic(FakeMatch(expr)) - result.append(replacement) - else: - # Unmatched, keep original - result.append(text[start:i]) - else: - result.append(text[i]) - i += 1 - return ''.join(result) - - text = find_and_replace_arithmetic(text) - - # Step 3: Expand basic variables ($VAR, ${VAR}, $1, etc.) - # Use shared expansion logic to avoid code duplication - text = self._expand_basic_variables(text) - - return text - - def _expand_globs(self, commands): - """ - Expand glob patterns in command arguments - - Args: - commands: List of (cmd, args) tuples - - Returns: - List of (cmd, expanded_args) tuples - """ - import fnmatch - - expanded_commands = [] - - for cmd, args in commands: - expanded_args = [] - - for arg in args: - # Skip flags (arguments starting with -) - if arg.startswith('-'): - expanded_args.append(arg) - # Check if argument contains glob characters - elif '*' in arg or '?' in arg or '[' in arg: - # Try to expand the glob pattern - matches = self._match_glob_pattern(arg) - - if matches: - # Expand to matching files - expanded_args.extend(sorted(matches)) - else: - # No matches, keep original pattern - expanded_args.append(arg) - else: - # Not a glob pattern, keep as is - expanded_args.append(arg) - - expanded_commands.append((cmd, expanded_args)) - - return expanded_commands - - def _match_glob_pattern(self, pattern: str): - """ - Match a glob pattern against files in the filesystem - - Args: - pattern: Glob pattern (e.g., "*.txt", "/local/*.log") - - Returns: - List of matching file paths - """ - import fnmatch - import os - - # Resolve the pattern to absolute path - if pattern.startswith('/'): - # Absolute pattern - dir_path = os.path.dirname(pattern) or '/' - file_pattern = os.path.basename(pattern) - else: - # Relative pattern - dir_path = self.cwd - file_pattern = pattern - - matches = [] - - try: - # List files in the directory - entries = self.filesystem.list_directory(dir_path) - - for entry in entries: - # Match against pattern - if fnmatch.fnmatch(entry['name'], file_pattern): - # Build full path - if dir_path == '/': - full_path = '/' + entry['name'] - else: - full_path = dir_path + '/' + entry['name'] - - matches.append(full_path) - except Exception as e: - # Directory doesn't exist or other error - # Return empty list to keep original pattern - pass - - return matches - - def _needs_more_input(self, line: str) -> bool: - """ - Check if the line needs more input (multiline continuation) - - Returns True if: - - Line ends with backslash \ - - Unclosed quotes (single or double) - - Unclosed brackets/parentheses - """ - # Check for backslash continuation - if line.rstrip().endswith('\\'): - return True - - # Check for unclosed quotes - in_single_quote = False - in_double_quote = False - escape_next = False - - for char in line: - if escape_next: - escape_next = False - continue - - if char == '\\': - escape_next = True - continue - - if char == '"' and not in_single_quote: - in_double_quote = not in_double_quote - elif char == "'" and not in_double_quote: - in_single_quote = not in_single_quote - - if in_single_quote or in_double_quote: - return True - - # Check for unclosed brackets/parentheses - bracket_count = 0 - paren_count = 0 - - for char in line: - if char == '(': - paren_count += 1 - elif char == ')': - paren_count -= 1 - elif char == '{': - bracket_count += 1 - elif char == '}': - bracket_count -= 1 - - if bracket_count > 0 or paren_count > 0: - return True - - return False - - def resolve_path(self, path: str) -> str: - """ - Resolve a relative or absolute path to an absolute path - - Args: - path: Path to resolve (can be relative or absolute) - - Returns: - Absolute path - """ - if not path: - return self.cwd - - # Already absolute - if path.startswith('/'): - # Normalize the path (remove redundant slashes, handle . and ..) - return os.path.normpath(path) - - # Relative path - join with cwd - full_path = os.path.join(self.cwd, path) - # Normalize to handle . and .. - return os.path.normpath(full_path) - - def execute_for_loop(self, lines: List[str]) -> int: - """ - Execute a for/do/done loop - - Args: - lines: List of lines making up the for loop - - Returns: - Exit code of last executed command - """ - parsed = self.control_parser.parse_for_loop(lines) - - if not parsed: - self.console.print("[red]Syntax error: invalid for loop syntax[/red]", highlight=False) - self.console.print("[yellow]Expected: for var in items; do commands; done[/yellow]", highlight=False) - return 1 - - try: - return self.executor.execute_for(parsed) - except BreakException: - # Break at top level - should not happen normally - return 0 - except ContinueException: - # Continue at top level - should not happen normally - return 0 - - def execute_while_loop(self, lines: List[str]) -> int: - """ - Execute a while/do/done loop - - Args: - lines: List of lines making up the while loop - - Returns: - Exit code of last executed command - """ - parsed = self.control_parser.parse_while_loop(lines) - - if not parsed: - self.console.print("[red]Syntax error: invalid while loop syntax[/red]", highlight=False) - self.console.print("[yellow]Expected: while condition; do commands; done[/yellow]", highlight=False) - return 1 - - try: - return self.executor.execute_while(parsed) - except BreakException: - # Break at top level - should not happen normally - return 0 - except ContinueException: - # Continue at top level - should not happen normally - return 0 - - def _parse_for_loop(self, lines: List[str]) -> dict: - """ - Parse a for/in/do/done loop from a list of lines - - Returns: - Dict with structure: { - 'var': variable_name, - 'items': [list of items], - 'commands': [list of commands] - } - """ - result = { - 'var': None, - 'items': [], - 'commands': [] - } - - state = 'for' # States: 'for', 'do' - first_for_parsed = False # Track if we've parsed the first for statement - - i = 0 - while i < len(lines): - line = lines[i].strip() - i += 1 - - if not line or line.startswith('#'): - continue - - # Strip comments before checking keywords - line_no_comment = self._strip_comment(line).strip() - - if line_no_comment == 'done': - # End of for loop - break - elif line_no_comment == 'do': - state = 'do' - elif line_no_comment.startswith('do '): - # 'do' with command on same line - state = 'do' - cmd_after_do = line_no_comment[3:].strip() - if cmd_after_do: - result['commands'].append(cmd_after_do) - elif line_no_comment.startswith('for '): - # Only parse the FIRST for statement - # Nested for loops should be treated as commands - if not first_for_parsed: - # Parse: for var in item1 item2 item3 - # or: for var in item1 item2 item3; do - parts = line_no_comment[4:].strip() - - # Remove trailing '; do' or 'do' if present - if parts.endswith('; do'): - parts = parts[:-4].strip() - state = 'do' - elif parts.endswith(' do'): - parts = parts[:-3].strip() - state = 'do' - - # Split by 'in' keyword - if ' in ' in parts: - var_and_in = parts.split(' in ', 1) - result['var'] = var_and_in[0].strip() - items_str = var_and_in[1].strip() - - # Remove inline comments before processing - items_str = self._strip_comment(items_str) - - # Expand variables in items string first - items_str = self._expand_variables(items_str) - - # Split items by whitespace - # Use simple split() for word splitting after variable expansion - # This mimics bash's word splitting behavior - raw_items = items_str.split() - - # Expand glob patterns in each item - expanded_items = [] - for item in raw_items: - # Check if item contains glob characters - if '*' in item or '?' in item or '[' in item: - # Try to expand the glob pattern - matches = self._match_glob_pattern(item) - if matches: - # Add all matching files - expanded_items.extend(sorted(matches)) - else: - # No matches, keep original pattern - expanded_items.append(item) - else: - # Not a glob pattern, keep as is - expanded_items.append(item) - - result['items'] = expanded_items - first_for_parsed = True - else: - # Invalid for syntax - return None - else: - # This is a nested for loop - collect it as a single command block - if state == 'do': - result['commands'].append(line) - # Now collect the rest of the nested loop (do...done) - while i < len(lines): - nested_line = lines[i].strip() - result['commands'].append(nested_line) - # Strip comments before checking for 'done' - nested_line_no_comment = self._strip_comment(nested_line).strip() - if nested_line_no_comment == 'done': - break - i += 1 - else: - # Regular command in loop body - if state == 'do': - result['commands'].append(line) - elif state == 'for' and first_for_parsed: - # We're in 'for' state after parsing the for statement, - # but seeing a regular command before 'do' - this is a syntax error - return None - - # Validate the parsed result - # Must have: variable name, items, and at least reached 'do' state - if not result['var']: - return None - - return result - - def _parse_while_loop(self, lines: List[str]) -> dict: - """ - Parse a while/do/done loop from a list of lines - - Returns: - Dict with structure: { - 'condition': condition_command, - 'commands': [list of commands] - } - """ - result = { - 'condition': None, - 'commands': [] - } - - state = 'while' # States: 'while', 'do' - first_while_parsed = False # Track if we've parsed the first while statement - - i = 0 - while i < len(lines): - line = lines[i].strip() - i += 1 - - if not line or line.startswith('#'): - continue - - # Strip comments before checking keywords - line_no_comment = self._strip_comment(line).strip() - - if line_no_comment == 'done': - # End of while loop - break - elif line_no_comment == 'do': - state = 'do' - elif line_no_comment.startswith('do '): - # 'do' with command on same line - state = 'do' - cmd_after_do = line_no_comment[3:].strip() - if cmd_after_do: - result['commands'].append(cmd_after_do) - elif line_no_comment.startswith('while '): - # Only parse the FIRST while statement - # Nested while loops should be treated as commands - if not first_while_parsed: - # Parse: while condition - # or: while condition; do - condition = line_no_comment[6:].strip() - - # Remove trailing '; do' or 'do' if present - if condition.endswith('; do'): - condition = condition[:-4].strip() - state = 'do' - elif condition.endswith(' do'): - condition = condition[:-3].strip() - state = 'do' - - # Remove inline comments from condition - condition = self._strip_comment(condition) - - result['condition'] = condition - first_while_parsed = True - else: - # This is a nested while loop - collect it as a command - if state == 'do': - result['commands'].append(line) - # Now collect the rest of the nested loop (do...done) - while i < len(lines): - nested_line = lines[i].strip() - result['commands'].append(nested_line) - # Strip comments before checking for 'done' - nested_line_no_comment = self._strip_comment(nested_line).strip() - if nested_line_no_comment == 'done': - break - i += 1 - else: - # Regular command in loop body - if state == 'do': - result['commands'].append(line) - elif state == 'while' and first_while_parsed: - # We're in 'while' state after parsing the while statement, - # but seeing a regular command before 'do' - this is a syntax error - return None - - # Validate the parsed result - # Must have: condition and at least reached 'do' state - if not result['condition']: - return None - - return result - - def execute_if_statement(self, lines: List[str]) -> int: - """ - Execute an if/then/else/fi statement - - Args: - lines: List of lines making up the if statement - - Returns: - Exit code of executed commands - """ - parsed = self.control_parser.parse_if_statement(lines) - - # Check if parsing was successful - if not parsed or not parsed.branches: - self.console.print("[red]Syntax error: invalid if statement syntax[/red]", highlight=False) - self.console.print("[yellow]Expected: if condition; then commands; fi[/yellow]", highlight=False) - return 1 - - # Execute using the new executor - exceptions will propagate - return self.executor.execute_if(parsed) - - def _parse_if_statement(self, lines: List[str]) -> dict: - """ - Parse an if/then/else/fi statement from a list of lines - - Returns: - Dict with structure: { - 'conditions': [(condition_cmd, commands_block), ...], - 'else_block': [commands] or None - } - """ - result = { - 'conditions': [], - 'else_block': None - } - - current_block = [] - current_condition = None - state = 'if' # States: 'if', 'then', 'elif', 'else' - - for line in lines: - line = line.strip() - - if not line or line.startswith('#'): - continue - - if line == 'fi': - # End of if statement - if state == 'then' and current_condition is not None: - result['conditions'].append((current_condition, current_block)) - elif state == 'else': - result['else_block'] = current_block - break - elif line == 'then': - state = 'then' - current_block = [] - elif line.startswith('then '): - # 'then' with command on same line (e.g., "then echo foo") - state = 'then' - current_block = [] - # Extract command after 'then' - cmd_after_then = line[5:].strip() - if cmd_after_then: - current_block.append(cmd_after_then) - elif line.startswith('elif '): - # Save previous condition block - if current_condition is not None: - result['conditions'].append((current_condition, current_block)) - # Start new condition - condition_part = line[5:].strip() - # Remove inline comments before processing - condition_part = self._strip_comment(condition_part) - # Check if 'then' is on the same line - has_then = condition_part.endswith(' then') - # Remove trailing 'then' if present on same line - if has_then: - condition_part = condition_part[:-5].strip() - current_condition = condition_part.rstrip(';') - # If 'then' was on same line, move to 'then' state - state = 'then' if has_then else 'if' - current_block = [] - elif line == 'else': - # Save previous condition block - if current_condition is not None: - result['conditions'].append((current_condition, current_block)) - state = 'else' - current_block = [] - current_condition = None - elif line.startswith('else '): - # 'else' with command on same line - if current_condition is not None: - result['conditions'].append((current_condition, current_block)) - state = 'else' - current_block = [] - current_condition = None - # Extract command after 'else' - cmd_after_else = line[5:].strip() - if cmd_after_else: - current_block.append(cmd_after_else) - elif line.startswith('if '): - # Initial if statement - extract condition - condition_part = line[3:].strip() - # Remove inline comments before processing - condition_part = self._strip_comment(condition_part) - # Check if 'then' is on the same line - has_then = condition_part.endswith(' then') - # Remove trailing 'then' if present on same line - if has_then: - condition_part = condition_part[:-5].strip() - current_condition = condition_part.rstrip(';') - # If 'then' was on same line, move to 'then' state - state = 'then' if has_then else 'if' - if has_then: - current_block = [] - else: - # Regular command in current block - if state == 'then' or state == 'else': - current_block.append(line) - - return result - - def _parse_function_definition(self, lines: List[str]) -> Optional[dict]: - """ - Parse a function definition from a list of lines - - Syntax: - function_name() { - commands - } - - Or: - function function_name { - commands - } - - Or single-line: - function_name() { commands; } - - Returns: - Dict with structure: { - 'name': function_name, - 'body': [list of commands] - } - """ - result = { - 'name': None, - 'body': [] - } - - if not lines: - return None - - first_line = lines[0].strip() - - # Check for single-line function: function_name() { commands... } - import re - single_line_match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{(.+)\}', first_line) - if not single_line_match: - single_line_match = re.match(r'^function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{(.+)\}', first_line) - - if single_line_match: - # Single-line function - result['name'] = single_line_match.group(1) - body = single_line_match.group(2).strip() - # Split by semicolons to get individual commands - if ';' in body: - result['body'] = [cmd.strip() for cmd in body.split(';') if cmd.strip()] - else: - result['body'] = [body] - return result - - # Check for multi-line function_name() { syntax - match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{?\s*$', first_line) - if not match: - # Check for function function_name { syntax - match = re.match(r'^function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{?\s*$', first_line) - - if not match: - return None - - result['name'] = match.group(1) - - # Collect function body - # If first line ends with {, start from next line - # Otherwise, expect { on next line - start_index = 1 - if not first_line.endswith('{'): - # Look for opening brace - if start_index < len(lines) and lines[start_index].strip() == '{': - start_index += 1 - - # Collect lines until closing } - brace_depth = 1 - for i in range(start_index, len(lines)): - line = lines[i].strip() - - # Skip comments and empty lines - if not line or line.startswith('#'): - continue - - # Check for closing brace - if line == '}': - brace_depth -= 1 - if brace_depth == 0: - break - elif '{' in line: - # Track nested braces - brace_depth += line.count('{') - brace_depth -= line.count('}') - - result['body'].append(lines[i]) - - return result - - def execute_function(self, func_name: str, args: List[str]) -> int: - """ - Execute a user-defined function - - Delegates to executor.execute_function_call() which handles: - - Parameter passing ($1, $2, etc.) - - Local variable scope - - Return value handling via ReturnException - - Proper cleanup on exit - - Args: - func_name: Function name - args: Function arguments - - Returns: - Exit code of function execution - """ - return self.executor.execute_function_call(func_name, args) - - def execute(self, command_line: str, stdin_data: Optional[bytes] = None, heredoc_data: Optional[bytes] = None) -> int: - """ - Execute a command line (possibly with pipelines and redirections) - - Args: - command_line: Command string to execute - stdin_data: Optional stdin data to provide to first command - heredoc_data: Optional heredoc data (for << redirections) - - Returns: - Exit code of the pipeline - """ - # Strip comments from the command line - command_line = self._strip_comment(command_line) - - # If command is empty after stripping comments, return success - if not command_line.strip(): - return 0 - - # Check for function definition - import re - # Match both function_name() { ... } and function function_name { ... } - func_def_match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*\{', command_line.strip()) - if not func_def_match: - func_def_match = re.match(r'^function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{', command_line.strip()) - - if func_def_match: - # Check if it's a complete single-line function - if '}' in command_line: - # Single-line function definition - use new AST parser - lines = [command_line] - func_ast = self.control_parser.parse_function_definition(lines) - if func_ast and func_ast.name: - # Store as AST-based function - self.functions[func_ast.name] = { - 'name': func_ast.name, - 'body': func_ast.body, - 'is_ast': True - } - return 0 - else: - self.console.print("[red]Syntax error: invalid function definition[/red]", highlight=False) - return 1 - else: - # Multi-line function - signal to REPL to collect more lines - return EXIT_CODE_FUNCTION_DEF_NEEDED - - # Also check for function definition without opening brace on first line - func_def_match2 = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)\s*$', command_line.strip()) - if not func_def_match2: - func_def_match2 = re.match(r'^function\s+([A-Za-z_][A-Za-z0-9_]*)\s*$', command_line.strip()) - - if func_def_match2: - # Function definition without opening brace - signal to collect more lines - return EXIT_CODE_FUNCTION_DEF_NEEDED - - # Check for for loop (special handling required) - if command_line.strip().startswith('for '): - # Check if it's a complete single-line for loop - # Look for 'done' as a separate word/keyword, not as substring - import re - if re.search(r'\bdone\b', command_line): - # Single-line for loop - parse and execute directly - parts = re.split(r';\s*', command_line) - lines = [part.strip() for part in parts if part.strip()] - return self.execute_for_loop(lines) - else: - # Multi-line for loop - signal to REPL to collect more lines - # Return special code to signal for loop collection needed - return EXIT_CODE_FOR_LOOP_NEEDED - - # Check for while loop (special handling required) - if command_line.strip().startswith('while '): - # Check if it's a complete single-line while loop - # Look for 'done' as a separate word/keyword, not as substring - import re - if re.search(r'\bdone\b', command_line): - # Single-line while loop - parse and execute directly - parts = re.split(r';\s*', command_line) - lines = [part.strip() for part in parts if part.strip()] - return self.execute_while_loop(lines) - else: - # Multi-line while loop - signal to REPL to collect more lines - # Return special code to signal while loop collection needed - return EXIT_CODE_WHILE_LOOP_NEEDED - - # Check for if statement (special handling required) - if command_line.strip().startswith('if '): - # Check if it's a complete single-line if statement - # Look for 'fi' as a separate word/keyword, not as substring - import re - if re.search(r'\bfi\b', command_line): - # Single-line if statement - parse and execute directly - # Split by semicolons but preserve the structure - # Split by '; ' while keeping keywords intact - parts = re.split(r';\s*', command_line) - lines = [part.strip() for part in parts if part.strip()] - return self.execute_if_statement(lines) - else: - # Multi-line if statement - signal to REPL to collect more lines - # Return special code to signal if statement collection needed - return EXIT_CODE_IF_STATEMENT_NEEDED - - # Check for variable assignment (VAR=value) - if '=' in command_line and not command_line.strip().startswith('='): - parts = command_line.split('=', 1) - if len(parts) == 2: - var_name = parts[0].strip() - # Check if it's a valid variable name (not a command with = in args) - if var_name and var_name.replace('_', '').isalnum() and not ' ' in var_name: - var_value = parts[1].strip() - - # Remove outer quotes if present (both single and double) - if len(var_value) >= 2: - if (var_value[0] == '"' and var_value[-1] == '"') or \ - (var_value[0] == "'" and var_value[-1] == "'"): - var_value = var_value[1:-1] - - # Expand variables after removing quotes - var_value = self._expand_variables(var_value) - self._set_variable(var_name, var_value) - return 0 - - # Expand variables in command line - command_line = self._expand_variables(command_line) - - # Handle && and || operators (conditional execution) - # Split by && and || while preserving which operator was used - if '&&' in command_line or '||' in command_line: - # Parse conditional chains: cmd1 && cmd2 || cmd3 - # We need to respect operator precedence and short-circuit evaluation - parts = [] - operators = [] - current = [] - i = 0 - while i < len(command_line): - if i < len(command_line) - 1: - two_char = command_line[i:i+2] - if two_char == '&&' or two_char == '||': - parts.append(''.join(current).strip()) - operators.append(two_char) - current = [] - i += 2 - continue - current.append(command_line[i]) - i += 1 - if current: - parts.append(''.join(current).strip()) - - # Execute with short-circuit evaluation - if parts: - last_exit_code = self.execute(parts[0], stdin_data=stdin_data, heredoc_data=heredoc_data) - for i, op in enumerate(operators): - if op == '&&': - # Execute next only if previous succeeded - if last_exit_code == 0: - last_exit_code = self.execute(parts[i+1], stdin_data=None, heredoc_data=None) - # else: skip execution, keep last_exit_code - elif op == '||': - # Execute next only if previous failed - if last_exit_code != 0: - last_exit_code = self.execute(parts[i+1], stdin_data=None, heredoc_data=None) - else: - # Previous succeeded, set exit code to 0 and don't execute next - last_exit_code = 0 - return last_exit_code - - # Parse the command line with redirections - commands, redirections = self.parser.parse_command_line(command_line) - - # Expand globs in command arguments - commands = self._expand_globs(commands) - - # If heredoc is detected but no data provided, return special code to signal REPL - # to read heredoc content - if 'heredoc_delimiter' in redirections and heredoc_data is None: - # Return special code to signal that heredoc data is needed - return EXIT_CODE_HEREDOC_NEEDED - - # If heredoc data is provided, use it as stdin - if heredoc_data is not None: - stdin_data = heredoc_data - - if not commands: - return 0 - - # Check if this is a user-defined function call (must be single command, not in pipeline) - if len(commands) == 1: - cmd_name, cmd_args = commands[0] - if cmd_name in self.functions: - # Execute user-defined function - return self.execute_function(cmd_name, cmd_args) - - # Special handling for cd command (must be a single command, not in pipeline) - # Using metadata instead of hardcoded check - if len(commands) == 1 and CommandMetadata.changes_cwd(commands[0][0]): - cmd, args = commands[0] - # Resolve target path - target = args[0] if args else '/' - resolved_path = self.resolve_path(target) - - # Verify the directory exists - try: - entries = self.filesystem.list_directory(resolved_path) - # Successfully listed - it's a valid directory - self.cwd = resolved_path - return 0 - except Exception as e: - error_msg = str(e) - if "No such file or directory" in error_msg or "not found" in error_msg.lower(): - self.console.print(f"[red]cd: {target}: No such file or directory[/red]", highlight=False) - else: - self.console.print(f"[red]cd: {target}: {error_msg}[/red]", highlight=False) - return 1 - - # Resolve paths in redirections - if 'stdin' in redirections: - input_file = self.resolve_path(redirections['stdin']) - try: - # Use AGFS to read input file - stdin_data = self.filesystem.read_file(input_file) - except AGFSClientError as e: - error_msg = self.filesystem.get_error_message(e) - self.console.print(f"[red]shell: {error_msg}[/red]", highlight=False) - return 1 - except Exception as e: - self.console.print(f"[red]shell: {input_file}: {str(e)}[/red]", highlight=False) - return 1 - - # Build processes for each command - processes = [] - for i, (cmd, args) in enumerate(commands): - # Get the executor for this command - executor = get_builtin(cmd) - - # Resolve relative paths in arguments (for file-related commands) - # Using metadata instead of hardcoded list - if CommandMetadata.needs_path_resolution(cmd): - resolved_args = [] - skip_next = False - for j, arg in enumerate(args): - # Skip if this is a flag value (e.g., the "2" in "-n 2") - if skip_next: - resolved_args.append(arg) - skip_next = False - continue - - # Skip flags (starting with -) - if arg.startswith('-'): - resolved_args.append(arg) - # Check if this flag takes a value (e.g., -n, -L, -d, -f) - if arg in ['-n', '-L', '-d', '-f', '-t', '-c'] and j + 1 < len(args): - skip_next = True - continue - - # Skip pure numbers (they're likely option values, not paths) - try: - float(arg) - resolved_args.append(arg) - continue - except ValueError: - pass - - # Resolve path - resolved_args.append(self.resolve_path(arg)) - args = resolved_args - - # Create streams - if i == 0 and stdin_data is not None: - stdin = InputStream.from_bytes(stdin_data) - else: - stdin = InputStream.from_bytes(b'') - - # For streaming output: if no redirections and last command in pipeline, - # output directly to real stdout for real-time streaming - if 'stdout' not in redirections and i == len(commands) - 1: - stdout = OutputStream.from_stdout() - else: - stdout = OutputStream.to_buffer() - - stderr = ErrorStream.to_buffer() - - # Create process with filesystem, cwd, and env - process = Process( - command=cmd, - args=args, - stdin=stdin, - stdout=stdout, - stderr=stderr, - executor=executor, - filesystem=self.filesystem, - env=self.env - ) - # Pass cwd to process for pwd command - process.cwd = self.cwd - processes.append(process) - - # Special case: direct streaming from stdin to file - # When: single streaming-capable command with no args, stdin from pipe, output to file - # Implementation: Loop and write chunks (like agfs-shell's write --stream) - # Using metadata instead of hardcoded check for 'cat' - if ('stdout' in redirections and - len(processes) == 1 and - CommandMetadata.supports_streaming(processes[0].command) and - not processes[0].args and - stdin_data is None): - - output_file = self.resolve_path(redirections['stdout']) - mode = redirections.get('stdout_mode', 'write') - - try: - # Streaming write: read chunks and write each one separately - # This enables true streaming (each chunk sent immediately to server) - chunk_size = 8192 # 8KB chunks - total_bytes = 0 - is_first_chunk = True - write_response = None - - while True: - chunk = sys.stdin.buffer.read(chunk_size) - if not chunk: - break - - # First chunk: overwrite or append based on mode - # Subsequent chunks: always append - append = (mode == 'append') or (not is_first_chunk) - - # Write chunk immediately (separate HTTP request per chunk) - write_response = self.filesystem.write_file(output_file, chunk, append=append) - total_bytes += len(chunk) - is_first_chunk = False - - exit_code = 0 - stderr_data = b'' - except AGFSClientError as e: - error_msg = self.filesystem.get_error_message(e) - self.console.print(f"[red]shell: {error_msg}[/red]", highlight=False) - return 1 - except Exception as e: - self.console.print(f"[red]shell: {output_file}: {str(e)}[/red]", highlight=False) - return 1 - else: - # Normal execution path - pipeline = Pipeline(processes) - exit_code = pipeline.execute() - - # Get results - stdout_data = pipeline.get_stdout() - stderr_data = pipeline.get_stderr() - - # Handle output redirection (>) - if 'stdout' in redirections: - output_file = self.resolve_path(redirections['stdout']) - mode = redirections.get('stdout_mode', 'write') - append = (mode == 'append') - try: - # Use AGFS to write output file - self.filesystem.write_file(output_file, stdout_data, append=append) - except AGFSClientError as e: - error_msg = self.filesystem.get_error_message(e) - self.console.print(f"[red]shell: {error_msg}[/red]", highlight=False) - return 1 - except Exception as e: - self.console.print(f"[red]shell: {output_file}: {str(e)}[/red]", highlight=False) - return 1 - - # Output handling - if 'stdout' not in redirections: - # Check if we need to add a newline - # Get the last process to check if output ended with newline - last_process = processes[-1] if processes else None - - # Only output if we used buffered output (not direct stdout) - # When using OutputStream.from_stdout(), data was already written directly - if stdout_data: - try: - # Decode and use rich console for output - text = stdout_data.decode('utf-8', errors='replace') - self.console.print(text, end='', highlight=False) - # Ensure output ends with newline (only in interactive mode) - if self.interactive and text and not text.endswith('\n'): - self.console.print(highlight=False) - except Exception: - # Fallback to raw output if decoding fails - sys.stdout.buffer.write(stdout_data) - sys.stdout.buffer.flush() - # Ensure output ends with newline (only in interactive mode) - if self.interactive and stdout_data and not stdout_data.endswith(b'\n'): - sys.stdout.write('\n') - sys.stdout.flush() - elif last_process and hasattr(last_process.stdout, 'ends_with_newline'): - # When using from_stdout() (direct output), check if we need newline (only in interactive mode) - if self.interactive and not last_process.stdout.ends_with_newline(): - sys.stdout.write('\n') - sys.stdout.flush() - - # Handle error redirection (2>) - if 'stderr' in redirections: - error_file = self.resolve_path(redirections['stderr']) - mode = redirections.get('stderr_mode', 'write') - append = (mode == 'append') - try: - # Use AGFS to write error file - write_response = self.filesystem.write_file(error_file, stderr_data, append=append) - # Display write response if it contains data - if write_response and write_response != "OK": - self.console.print(write_response, highlight=False) - except AGFSClientError as e: - error_msg = self.filesystem.get_error_message(e) - self.console.print(f"[red]shell: {error_msg}[/red]", highlight=False) - return 1 - except Exception as e: - self.console.print(f"[red]shell: {error_file}: {str(e)}[/red]", highlight=False) - return 1 - else: - # Output to stderr if no redirection - if stderr_data: - try: - # Decode and use rich console for stderr - text = stderr_data.decode('utf-8', errors='replace') - self.console.print(f"[red]{text}[/red]", end='', highlight=False) - except Exception: - # Fallback to raw output - sys.stderr.buffer.write(stderr_data) - sys.stderr.buffer.flush() - - return exit_code - - def repl(self): - """Run interactive REPL""" - # Set interactive mode flag - self.interactive = True - self.console.print(""" __ __ __ - /\\ / _ |_ (_ -/--\\\\__)| __) - """) - self.console.print(f"[bold cyan]agfs-shell[/bold cyan] v{__version__}", highlight=False) - - # Check server connection - exit if failed - if not self.filesystem.check_connection(): - self.console.print(f"[red]Error: Cannot connect to AGFS server at {self.server_url}[/red]", highlight=False) - self.console.print("Make sure the server is running.", highlight=False) - sys.exit(1) - - self.console.print(f"Connected to AGFS server at [green]{self.server_url}[/green]", highlight=False) - self.console.print("Type [cyan]'help'[/cyan] for help, [cyan]Ctrl+D[/cyan] or [cyan]'exit'[/cyan] to quit", highlight=False) - self.console.print(highlight=False) - - # Setup tab completion and history - history_loaded = False - try: - import readline - import os - from .completer import ShellCompleter - - completer = ShellCompleter(self.filesystem) - # Pass shell reference to completer for cwd - completer.shell = self - readline.set_completer(completer.complete) - - # Set up completion display hook for better formatting - try: - # Try to set display matches hook (GNU readline only) - def display_matches(substitution, matches, longest_match_length): - """Display completion matches in a clean format""" - # Print newline before matches - print() - - # Display matches in columns - if len(matches) <= 10: - # Few matches - display in a single column - for match in matches: - print(f" {match}") - else: - # Many matches - display in multiple columns - import shutil - term_width = shutil.get_terminal_size((80, 20)).columns - col_width = longest_match_length + 2 - num_cols = max(1, term_width // col_width) - - for i, match in enumerate(matches): - print(f" {match:<{col_width}}", end='') - if (i + 1) % num_cols == 0: - print() - print() - - # Re-display prompt - prompt = f"agfs:{self.cwd}> " - print(prompt + readline.get_line_buffer(), end='', flush=True) - - readline.set_completion_display_matches_hook(display_matches) - except AttributeError: - # libedit doesn't support display matches hook - pass - - # Different binding for libedit (macOS) vs GNU readline (Linux) - if 'libedit' in readline.__doc__: - # macOS/BSD libedit - readline.parse_and_bind("bind ^I rl_complete") - # Set completion display to show candidates properly - readline.parse_and_bind("set show-all-if-ambiguous on") - readline.parse_and_bind("set completion-display-width 0") - else: - # GNU readline - readline.parse_and_bind("tab: complete") - # Better completion display - readline.parse_and_bind("set show-all-if-ambiguous on") - readline.parse_and_bind("set completion-display-width 0") - - # Configure readline to use space and special chars as delimiters - # This allows path completion to work properly - readline.set_completer_delims(' \t\n;|&<>()') - - # Setup history - # History file location: use HISTFILE variable (modifiable via export command) - # Default: $HOME/.agfs_shell_history - history_file = os.path.expanduser(self.env.get('HISTFILE', '~/.agfs_shell_history')) - - # Set history length - readline.set_history_length(1000) - - # Try to load existing history - try: - readline.read_history_file(history_file) - history_loaded = True - except FileNotFoundError: - # History file doesn't exist yet - will be created on exit - pass - except Exception as e: - # Other errors - warn but continue - self.console.print(f"[yellow]Warning: Could not load history: {e}[/yellow]", highlight=False) - - except ImportError: - # readline not available (e.g., on Windows without pyreadline) - pass - - while self.running: - try: - # Read command (possibly multiline) - try: - # Primary prompt - prompt = f"agfs:{self.cwd}> " - line = input(prompt) - - # Start building the command - self.multiline_buffer = [line] - - # Check if we need more input - while self._needs_more_input(' '.join(self.multiline_buffer)): - # Secondary prompt (like bash PS2) - continuation_prompt = "> " - try: - next_line = input(continuation_prompt) - self.multiline_buffer.append(next_line) - except EOFError: - # Ctrl+D during continuation - cancel multiline - self.console.print(highlight=False) - self.multiline_buffer = [] - break - except KeyboardInterrupt: - # Ctrl+C during continuation - cancel multiline - self.console.print(highlight=False) - self.multiline_buffer = [] - break - - # Join all lines for the complete command - if not self.multiline_buffer: - continue - - # Join lines: preserve newlines in quotes, remove backslash continuations - full_command = [] - for i, line in enumerate(self.multiline_buffer): - if line.rstrip().endswith('\\'): - # Backslash continuation: remove \ and don't add newline - full_command.append(line.rstrip()[:-1]) - else: - # Regular line: add it - full_command.append(line) - # Add newline if not the last line - if i < len(self.multiline_buffer) - 1: - full_command.append('\n') - - command = ''.join(full_command).strip() - self.multiline_buffer = [] - - except EOFError: - # Ctrl+D - exit shell - self.console.print(highlight=False) - break - except KeyboardInterrupt: - # Ctrl+C during input - just start new line - self.console.print(highlight=False) - self.multiline_buffer = [] - continue - - # Handle special commands - if command in ('exit', 'quit'): - break - elif command == 'help': - self.show_help() - continue - elif not command: - continue - - # Execute command - try: - exit_code = self.execute(command) - - # Check if for-loop is needed - if exit_code == EXIT_CODE_FOR_LOOP_NEEDED: - # Collect for/do/done loop - for_lines = [command] - for_depth = 1 # Track nesting depth - try: - while True: - for_line = input("> ") - for_lines.append(for_line) - # Count nested for loops - stripped = for_line.strip() - if stripped.startswith('for '): - for_depth += 1 - elif stripped == 'done': - for_depth -= 1 - if for_depth == 0: - break - except EOFError: - # Ctrl+D before done - self.console.print("\nWarning: for-loop ended by end-of-file (wanted `done`)", highlight=False) - except KeyboardInterrupt: - # Ctrl+C during for-loop - cancel - self.console.print("\n^C", highlight=False) - continue - - # Execute the for loop - exit_code = self.execute_for_loop(for_lines) - # Update $? with the exit code - self.env['?'] = str(exit_code) - - # Check if while-loop is needed - elif exit_code == EXIT_CODE_WHILE_LOOP_NEEDED: - # Collect while/do/done loop - while_lines = [command] - while_depth = 1 # Track nesting depth - try: - while True: - while_line = input("> ") - while_lines.append(while_line) - # Count nested while loops - stripped = while_line.strip() - if stripped.startswith('while '): - while_depth += 1 - elif stripped == 'done': - while_depth -= 1 - if while_depth == 0: - break - except EOFError: - # Ctrl+D before done - self.console.print("\nWarning: while-loop ended by end-of-file (wanted `done`)", highlight=False) - except KeyboardInterrupt: - # Ctrl+C during while-loop - cancel - self.console.print("\n^C", highlight=False) - continue - - # Execute the while loop - exit_code = self.execute_while_loop(while_lines) - # Update $? with the exit code - self.env['?'] = str(exit_code) - - # Check if if-statement is needed - elif exit_code == EXIT_CODE_IF_STATEMENT_NEEDED: - # Collect if/then/else/fi statement - if_lines = [command] - try: - while True: - if_line = input("> ") - if_lines.append(if_line) - # Check if we reached the end with 'fi' - if if_line.strip() == 'fi': - break - except EOFError: - # Ctrl+D before fi - self.console.print("\nWarning: if-statement ended by end-of-file (wanted `fi`)", highlight=False) - except KeyboardInterrupt: - # Ctrl+C during if-statement - cancel - self.console.print("\n^C", highlight=False) - continue - - # Execute the if statement - exit_code = self.execute_if_statement(if_lines) - # Update $? with the exit code - self.env['?'] = str(exit_code) - - # Check if function definition is needed - elif exit_code == EXIT_CODE_FUNCTION_DEF_NEEDED: - # Collect function definition - func_lines = [command] - brace_depth = 1 # We've seen the opening { - try: - while True: - func_line = input("> ") - func_lines.append(func_line) - # Track braces - stripped = func_line.strip() - brace_depth += stripped.count('{') - brace_depth -= stripped.count('}') - if brace_depth == 0: - break - except EOFError: - # Ctrl+D before closing } - self.console.print("\nWarning: function definition ended by end-of-file (wanted `}`)", highlight=False) - except KeyboardInterrupt: - # Ctrl+C during function definition - cancel - self.console.print("\n^C", highlight=False) - continue - - # Parse and store the function using AST parser - func_ast = self.control_parser.parse_function_definition(func_lines) - if func_ast and func_ast.name: - # Store as AST-based function - self.functions[func_ast.name] = { - 'name': func_ast.name, - 'body': func_ast.body, - 'is_ast': True - } - exit_code = 0 - else: - self.console.print("[red]Syntax error: invalid function definition[/red]", highlight=False) - exit_code = 1 - - # Update $? with the exit code - self.env['?'] = str(exit_code) - - # Check if heredoc is needed - elif exit_code == EXIT_CODE_HEREDOC_NEEDED: - # Parse command to get heredoc delimiter - commands, redirections = self.parser.parse_command_line(command) - if 'heredoc_delimiter' in redirections: - delimiter = redirections['heredoc_delimiter'] - - # Read heredoc content - heredoc_lines = [] - try: - while True: - heredoc_line = input() - if heredoc_line.strip() == delimiter: - break - heredoc_lines.append(heredoc_line) - except EOFError: - # Ctrl+D before delimiter - self.console.print(f"\nWarning: here-document delimited by end-of-file (wanted `{delimiter}`)", highlight=False) - except KeyboardInterrupt: - # Ctrl+C during heredoc - cancel - self.console.print("\n^C", highlight=False) - continue - - # Join heredoc content - heredoc_content = '\n'.join(heredoc_lines) - if heredoc_lines: # Add final newline if there was content - heredoc_content += '\n' - - # Execute command again with heredoc data - exit_code = self.execute(command, heredoc_data=heredoc_content.encode('utf-8')) - # Update $? with the exit code - self.env['?'] = str(exit_code) - else: - # Normal command execution - update $? - # Skip special exit codes for internal use - if exit_code not in [ - EXIT_CODE_CONTINUE, - EXIT_CODE_BREAK, - EXIT_CODE_FOR_LOOP_NEEDED, - EXIT_CODE_WHILE_LOOP_NEEDED, - EXIT_CODE_IF_STATEMENT_NEEDED, - EXIT_CODE_HEREDOC_NEEDED, - EXIT_CODE_FUNCTION_DEF_NEEDED, - EXIT_CODE_RETURN - ]: - self.env['?'] = str(exit_code) - - except KeyboardInterrupt: - # Ctrl+C during command execution - interrupt command - self.console.print("\n^C", highlight=False) - continue - except Exception as e: - self.console.print(f"[red]Error: {e}[/red]", highlight=False) - - except KeyboardInterrupt: - # Ctrl+C at top level - start new line - self.console.print(highlight=False) - self.multiline_buffer = [] - continue - - # Save history before exiting - # Use current value of HISTFILE variable (may have been changed during session) - if 'HISTFILE' in self.env: - try: - import readline - import os - history_file = os.path.expanduser(self.env['HISTFILE']) - readline.write_history_file(history_file) - except Exception as e: - self.console.print(f"[yellow]Warning: Could not save history: {e}[/yellow]", highlight=False) - - self.console.print("[cyan]Goodbye![/cyan]", highlight=False) - - def show_help(self): - """Show help message""" - help_text = """[bold cyan]agfs-shell[/bold cyan] - Experimental shell with AGFS integration - -[bold yellow]File System Commands (AGFS):[/bold yellow] - [green]cd[/green] [path] - Change current directory (supports relative paths) - [green]pwd[/green] - Print current working directory - [green]ls[/green] [-l] [path] - List directory contents (use -l for details, defaults to cwd) - [green]mkdir[/green] path - Create directory - [green]rm[/green] [-r] path - Remove file or directory - [green]cat[/green] [file...] - Read and concatenate files - [green]stat[/green] path - Display file status - [green]cp[/green] [-r] src dest - Copy files (local:path for local filesystem) - [green]upload[/green] [-r] local agfs - Upload local file/directory to AGFS - [green]download[/green] [-r] agfs local - Download AGFS file/directory to local - -[bold yellow]Text Processing Commands:[/bold yellow] - [green]echo[/green] [args...] - Print arguments to stdout - [green]grep[/green] [opts] pattern [files] - Search for pattern - Options: -i (ignore case), -v (invert), -n (line numbers), -c (count) - [green]jq[/green] filter [files] - Process JSON data - [green]wc[/green] [-l] [-w] [-c] - Count lines, words, and bytes - [green]head[/green] [-n count] - Output first N lines (default 10) - [green]tail[/green] [-n count] - Output last N lines (default 10) - [green]sort[/green] [-r] - Sort lines (use -r for reverse) - [green]uniq[/green] - Remove duplicate adjacent lines - [green]tr[/green] set1 set2 - Translate characters - -[bold yellow]Environment Variables:[/bold yellow] - [green]export[/green] VAR=value - Set environment variable - [green]env[/green] - Display all environment variables - [green]unset[/green] VAR - Remove environment variable - $VAR or ${{VAR}} - Reference variable value - -[bold yellow]Control Flow:[/bold yellow] - [green]if[/green] condition; then - commands - elif condition; then - commands - else - commands - fi - - [green]for[/green] var in item1 item2 item3; do - commands - done - - [green]test[/green] or [green][[/green] expr [green]][/green] - Test conditions - File: -f (file), -d (directory), -e (exists) - String: -z (empty), -n (non-empty), = (equal), != (not equal) - Integer: -eq -ne -gt -lt -ge -le - -[bold yellow]Pipeline Syntax:[/bold yellow] - command1 | command2 | command3 - -[bold yellow]Multiline Input & Heredoc:[/bold yellow] - Line ending with \\ - Continue on next line - Unclosed quotes (" or ') - Continue until closed - Unclosed () or {{}} - Continue until closed - - [green]cat << EOF[/green] - Heredoc (write until EOF marker) - Multiple lines of text - Variables like $VAR are expanded - EOF - - [green]cat << 'EOF'[/green] - Literal heredoc (no expansion) - Text with literal $VAR - EOF - -[bold yellow]Redirection Operators:[/bold yellow] - < file - Read input from AGFS file - > file - Write output to AGFS file (overwrite) - >> file - Append output to AGFS file - 2> file - Write stderr to AGFS file - 2>> file - Append stderr to AGFS file - -[bold yellow]Path Resolution:[/bold yellow] - - Absolute paths start with / (e.g., /local/file.txt) - - Relative paths are resolved from current directory (e.g., file.txt, ../dir) - - Special: . (current dir), .. (parent dir) - - Tab completion works for both absolute and relative paths - -[bold yellow]Examples:[/bold yellow] - [dim]# File operations[/dim] - [dim]>[/dim] cd /local/mydir - [dim]>[/dim] cat file.txt | grep -i "error" | wc -l - [dim]>[/dim] cp local:~/data.txt /local/backup.txt - - [dim]# Variables[/dim] - [dim]>[/dim] export NAME="world" - [dim]>[/dim] echo "Hello $NAME" - - [dim]# Conditionals[/dim] - [dim]>[/dim] if test -f myfile.txt; then - echo "File exists" - else - echo "File not found" - fi - - [dim]# Loops[/dim] - [dim]>[/dim] for file in *.txt; do - echo "Processing $file" - cat $file | grep "TODO" - done - - [dim]# Heredoc[/dim] - [dim]>[/dim] cat << EOF > config.json - { - "name": "$NAME", - "version": "1.0" - } - EOF - - [dim]# JSON processing with jq[/dim] - [dim]>[/dim] echo '{"name":"test","value":42}' | jq '.name' - [dim]>[/dim] cat data.json | jq '.items[] | select(.active == true)' - - [dim]# Advanced grep[/dim] - [dim]>[/dim] grep -n "function" code.py - [dim]>[/dim] grep -r -i "error" *.log | grep -v "debug" - - [dim]# Sleep/delay execution[/dim] - [dim]>[/dim] echo "Starting..." && sleep 2 && echo "Done!" - [dim]>[/dim] for i in 1 2 3; do echo "Step $i"; sleep 1; done - -[bold yellow]Utility Commands:[/bold yellow] - [green]sleep[/green] seconds - Pause execution for specified seconds (supports decimals) - -[bold yellow]Special Commands:[/bold yellow] - [green]help[/green] - Show this help - [green]exit[/green], [green]quit[/green] - Exit the shell - [green]Ctrl+C[/green] - Interrupt current command - [green]Ctrl+D[/green] - Exit the shell - -[dim]Note: All file operations use AGFS. Paths like /local/, /s3fs/, /sqlfs/ - refer to different AGFS filesystem backends.[/dim] -""" - self.console.print(help_text, highlight=False) diff --git a/third_party/agfs/agfs-shell/agfs_shell/streams.py b/third_party/agfs/agfs-shell/agfs_shell/streams.py deleted file mode 100644 index 7172d2519..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/streams.py +++ /dev/null @@ -1,260 +0,0 @@ -"""Stream classes for Unix-style I/O handling""" - -import sys -import io -from typing import Optional, Union, BinaryIO, TextIO, TYPE_CHECKING - -if TYPE_CHECKING: - from .filesystem import AGFSFileSystem - - -class Stream: - """Base class for I/O streams""" - - def __init__(self, fd: Optional[Union[int, BinaryIO, TextIO]] = None, mode: str = 'r'): - """ - Initialize a stream - - Args: - fd: File descriptor (int), file object, or None - mode: 'r' for read, 'w' for write, 'a' for append - """ - self.mode = mode - self._fd = fd - self._file = None - self._buffer = None - - if fd is None: - # Use in-memory buffer - if 'r' in mode: - self._buffer = io.BytesIO() - else: - self._buffer = io.BytesIO() - elif isinstance(fd, int): - # File descriptor number - self._file = open(fd, mode + 'b', buffering=0, closefd=False) - else: - # File-like object - self._file = fd - - def get_file(self) -> BinaryIO: - """Get the underlying file object""" - if self._buffer is not None: - return self._buffer - return self._file - - def read(self, size: int = -1) -> bytes: - """Read from stream""" - f = self.get_file() - return f.read(size) - - def readline(self) -> bytes: - """Read a line from stream""" - f = self.get_file() - return f.readline() - - def readlines(self) -> list: - """Read all lines from stream""" - f = self.get_file() - return f.readlines() - - def write(self, data: Union[bytes, str]) -> int: - """Write to stream""" - if isinstance(data, str): - data = data.encode('utf-8') - return self.get_file().write(data) - - def flush(self): - """Flush the stream""" - self.get_file().flush() - - def close(self): - """Close the stream""" - if self._file is not None and hasattr(self._file, 'close'): - self._file.close() - if self._buffer is not None: - # Don't close buffer, might need to read from it - pass - - def fileno(self) -> Optional[int]: - """Get file descriptor number""" - if self._fd is not None and isinstance(self._fd, int): - return self._fd - if self._file is not None and hasattr(self._file, 'fileno'): - try: - return self._file.fileno() - except: - pass - return None - - def get_value(self) -> bytes: - """ - Get the buffer contents (for buffer-based streams). - - NOTE: This method only works for buffer-based streams. For InputStream, - use read() or readlines() instead, as they properly support streaming - pipelines (StreamingInputStream reads from a queue, not a buffer). - - This method is primarily intended for OutputStream/ErrorStream to - retrieve command output after execution. - """ - if self._buffer is not None: - pos = self._buffer.tell() - self._buffer.seek(0) - data = self._buffer.read() - self._buffer.seek(pos) - return data - return b'' - - -class InputStream(Stream): - """ - Input stream (STDIN-like). - - To read data from an InputStream, always use read() or readlines() methods, - NOT get_value(). This ensures compatibility with streaming pipelines where - StreamingInputStream is used (which reads from a queue, not a buffer). - """ - - def __init__(self, fd: Optional[Union[int, BinaryIO, TextIO]] = None): - super().__init__(fd, mode='rb') - - @classmethod - def from_stdin(cls): - """Create from system stdin""" - return cls(sys.stdin.buffer) - - @classmethod - def from_bytes(cls, data: bytes): - """Create from bytes data""" - stream = cls(None) - stream._buffer = io.BytesIO(data) - return stream - - @classmethod - def from_string(cls, data: str): - """Create from string data""" - return cls.from_bytes(data.encode('utf-8')) - - -class OutputStream(Stream): - """Output stream (STDOUT-like)""" - - def __init__(self, fd: Optional[Union[int, BinaryIO, TextIO]] = None): - super().__init__(fd, mode='wb') - self._last_char = None # Track last written character - - def write(self, data: Union[bytes, str]) -> int: - """Write to stream and track last character""" - result = super().write(data) - # Track last character for newline checking - if data: - if isinstance(data, str): - data = data.encode('utf-8') - if len(data) > 0: - self._last_char = data[-1:] - return result - - def ends_with_newline(self) -> bool: - """Check if the last written data ended with a newline""" - return self._last_char == b'\n' if self._last_char else True - - @classmethod - def from_stdout(cls): - """Create from system stdout""" - return cls(sys.stdout.buffer) - - @classmethod - def to_buffer(cls): - """Create to in-memory buffer""" - return cls(None) - - -class ErrorStream(Stream): - """Error stream (STDERR-like)""" - - def __init__(self, fd: Optional[Union[int, BinaryIO, TextIO]] = None): - super().__init__(fd, mode='wb') - - @classmethod - def from_stderr(cls): - """Create from system stderr""" - return cls(sys.stderr.buffer) - - @classmethod - def to_buffer(cls): - """Create to in-memory buffer""" - return cls(None) - - -class AGFSOutputStream(OutputStream): - """Output stream that writes directly to AGFS file in streaming mode""" - - def __init__(self, filesystem: 'AGFSFileSystem', path: str, append: bool = False): - """ - Initialize AGFS output stream - - Args: - filesystem: AGFS filesystem instance - path: Target file path in AGFS - append: If True, append to file; if False, overwrite - """ - # Don't call super().__init__ as we handle buffering differently - self.mode = 'wb' - self._fd = None - self._file = None - self._buffer = io.BytesIO() # Temporary buffer - self._last_char = None # Track last written character - self.filesystem = filesystem - self.path = path - self.append = append - self._chunks = [] # Collect chunks - self._total_size = 0 - - def write(self, data: Union[bytes, str]) -> int: - """Write data to buffer""" - if isinstance(data, str): - data = data.encode('utf-8') - - # Track last character for newline checking - if data and len(data) > 0: - self._last_char = data[-1:] - - # Add to chunks - self._chunks.append(data) - self._total_size += len(data) - - # Also write to buffer for get_value() compatibility - self._buffer.write(data) - - return len(data) - - def ends_with_newline(self) -> bool: - """Check if the last written data ended with a newline""" - return self._last_char == b'\n' if self._last_char else True - - def flush(self): - """Flush accumulated data to AGFS""" - if not self._chunks: - return - - # Combine all chunks - data = b''.join(self._chunks) - - # Write to AGFS - try: - self.filesystem.write_file(self.path, data, append=self.append) - # After first write, switch to append mode for subsequent flushes - self.append = True - # Clear chunks - self._chunks = [] - self._total_size = 0 - except Exception as e: - # Re-raise to let caller handle - raise - - def close(self): - """Close stream and flush remaining data""" - self.flush() - if self._buffer is not None: - self._buffer.close() diff --git a/third_party/agfs/agfs-shell/agfs_shell/utils/__init__.py b/third_party/agfs/agfs-shell/agfs_shell/utils/__init__.py deleted file mode 100644 index ac0139a5f..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/utils/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -Utility functions for agfs-shell commands. -""" - -__all__ = ['formatters'] diff --git a/third_party/agfs/agfs-shell/agfs_shell/utils/formatters.py b/third_party/agfs/agfs-shell/agfs_shell/utils/formatters.py deleted file mode 100644 index ec9610552..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/utils/formatters.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Formatting utilities for agfs-shell commands. - -This module provides common formatting functions used across multiple commands. -""" - - -def mode_to_rwx(mode: int) -> str: - """ - Convert octal file mode to rwx string format. - - Args: - mode: File mode as integer (e.g., 0o100644 or 420 decimal) - - Returns: - String representation like 'rw-r--r--' - - Example: - >>> mode_to_rwx(0o644) - 'rw-r--r--' - >>> mode_to_rwx(0o755) - 'rwxr-xr-x' - """ - # Handle both full mode (e.g., 0o100644) and just permissions (e.g., 0o644 or 420 decimal) - # Extract last 9 bits for user/group/other permissions - perms = mode & 0o777 - - def _triple(val): - """Convert 3-bit value to rwx""" - r = 'r' if val & 4 else '-' - w = 'w' if val & 2 else '-' - x = 'x' if val & 1 else '-' - return r + w + x - - # Split into user, group, other (3 bits each) - user = (perms >> 6) & 7 - group = (perms >> 3) & 7 - other = perms & 7 - - return _triple(user) + _triple(group) + _triple(other) - - -def human_readable_size(size: int) -> str: - """ - Convert size in bytes to human-readable format. - - Args: - size: Size in bytes - - Returns: - Human-readable string like '1.5K', '2.3M', '100B' - - Example: - >>> human_readable_size(1024) - '1K' - >>> human_readable_size(1536) - '1.5K' - >>> human_readable_size(1048576) - '1M' - """ - units = ['B', 'K', 'M', 'G', 'T', 'P'] - unit_index = 0 - size_float = float(size) - - while size_float >= 1024.0 and unit_index < len(units) - 1: - size_float /= 1024.0 - unit_index += 1 - - if unit_index == 0: - # Bytes - no decimal - return f"{int(size_float)}{units[unit_index]}" - elif size_float >= 10: - # >= 10 - no decimal places - return f"{int(size_float)}{units[unit_index]}" - else: - # < 10 - one decimal place - return f"{size_float:.1f}{units[unit_index]}" - - -__all__ = ['mode_to_rwx', 'human_readable_size'] diff --git a/third_party/agfs/agfs-shell/agfs_shell/webapp_server.py b/third_party/agfs/agfs-shell/agfs_shell/webapp_server.py deleted file mode 100644 index 986fe9c87..000000000 --- a/third_party/agfs/agfs-shell/agfs_shell/webapp_server.py +++ /dev/null @@ -1,643 +0,0 @@ -"""Web application server for agfs-shell""" - -import asyncio -import json -import os -import sys -import io -from pathlib import Path -from typing import Optional - -try: - from aiohttp import web - import aiohttp_cors - AIOHTTP_AVAILABLE = True -except ImportError: - AIOHTTP_AVAILABLE = False - - -class ShellSession: - """A shell session for a WebSocket connection""" - - def __init__(self, shell, ws): - self.shell = shell - self.ws = ws - self.buffer = "" - # Initialize completer - from .completer import ShellCompleter - self.completer = ShellCompleter(self.shell.filesystem) - self.completer.shell = self.shell - - async def send(self, data: str): - """Send data to the WebSocket""" - if self.ws and not self.ws.closed: - await self.ws.send_str(data) - - def get_completions(self, text: str, line: str, cursor_pos: int) -> list: - """Get completion suggestions for the given text - - Args: - text: The word being completed - line: The full command line - cursor_pos: Cursor position in the line - - Returns: - List of completion suggestions - """ - # Determine if we're completing a command or a path - before_cursor = line[:cursor_pos] - - # Check if we're at the beginning (completing command) - if not before_cursor.strip() or before_cursor.strip() == text: - # Complete command names - return self.completer._complete_command(text) - else: - # Complete paths - return self.completer._complete_path(text) - - async def handle_command(self, command: str): - """Execute a command and send output to WebSocket""" - # Create a wrapper that has both text and binary interfaces - class BufferedTextIO: - def __init__(self): - self.text_buffer = io.StringIO() - self.byte_buffer = io.BytesIO() - # Create buffer attribute for binary writes - self.buffer = self - - def write(self, data): - if isinstance(data, bytes): - self.byte_buffer.write(data) - else: - self.text_buffer.write(data) - return len(data) - - def flush(self): - pass - - def getvalue(self): - text = self.text_buffer.getvalue() - binary = self.byte_buffer.getvalue() - if binary: - try: - text += binary.decode('utf-8', errors='replace') - except: - pass - return text - - # Capture stdout and stderr - old_stdout = sys.stdout - old_stderr = sys.stderr - stdout_buffer = BufferedTextIO() - stderr_buffer = BufferedTextIO() - - sys.stdout = stdout_buffer - sys.stderr = stderr_buffer - - try: - # Execute the command through shell - exit_code = self.shell.execute(command) - - # Get output - stdout = stdout_buffer.getvalue() - stderr = stderr_buffer.getvalue() - - # Send output to terminal (convert \n to \r\n for terminal) - if stdout: - stdout_formatted = stdout.replace('\n', '\r\n') - await self.send(stdout_formatted) - if stderr: - # Send stderr in red color (convert \n to \r\n) - stderr_formatted = stderr.replace('\n', '\r\n') - await self.send(f'\x1b[31m{stderr_formatted}\x1b[0m') - - return exit_code - - except Exception as e: - # Send error in red - await self.send(f'\x1b[31mError: {str(e)}\x1b[0m\r\n') - return 1 - finally: - sys.stdout = old_stdout - sys.stderr = old_stderr - - -class WebAppServer: - """HTTP server for the web application""" - - def __init__(self, shell, host='localhost', port=3000): - if not AIOHTTP_AVAILABLE: - raise ImportError( - "aiohttp is required for web app server. " - "Install with: uv sync --extra webapp" - ) - - self.shell = shell - self.host = host - self.port = port - self.app = None - self.runner = None - self.sessions = {} # WebSocket sessions - - async def handle_explorer(self, request): - """Get directory structure for Explorer (optimized API)""" - path = request.query.get('path', '/') - - try: - # Use filesystem API directly for better performance - entries = self.shell.filesystem.list_directory(path) - - # Format entries for frontend - files = [] - for entry in entries: - name = entry.get('name', '') - if name and name not in ['.', '..']: - # AGFS API returns 'isDir' instead of 'type' - is_dir = entry.get('isDir', False) - file_type = 'directory' if is_dir else 'file' - - files.append({ - 'name': name, - 'path': f"{path.rstrip('/')}/{name}" if path != '/' else f"/{name}", - 'type': file_type, - 'size': entry.get('size', 0), - 'mtime': entry.get('mtime', ''), - }) - - # Sort: directories first, then by name - files.sort(key=lambda x: (x['type'] != 'directory', x['name'].lower())) - - return web.json_response({ - 'path': path, - 'files': files - }) - - except Exception as e: - return web.json_response( - {'error': str(e), 'path': path}, - status=500 - ) - - async def handle_list_files(self, request): - """List files in a directory (legacy, kept for compatibility)""" - path = request.query.get('path', '/') - - try: - # Use filesystem API directly - entries = self.shell.filesystem.list_directory(path) - - files = [] - for entry in entries: - name = entry.get('name', '') - if name and name not in ['.', '..']: - # AGFS API returns 'isDir' instead of 'type' - is_dir = entry.get('isDir', False) - file_type = 'directory' if is_dir else 'file' - - files.append({ - 'name': name, - 'type': file_type - }) - - return web.json_response({'files': files}) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_read_file(self, request): - """Read file contents""" - path = request.query.get('path', '') - - if not path: - return web.json_response( - {'error': 'Path is required'}, - status=400 - ) - - try: - # Use BufferedTextIO to handle both text and binary output - class BufferedTextIO: - def __init__(self): - self.text_buffer = io.StringIO() - self.byte_buffer = io.BytesIO() - self.buffer = self - - def write(self, data): - if isinstance(data, bytes): - self.byte_buffer.write(data) - else: - self.text_buffer.write(data) - return len(data) - - def flush(self): - pass - - def getvalue(self): - text = self.text_buffer.getvalue() - binary = self.byte_buffer.getvalue() - if binary: - try: - text += binary.decode('utf-8', errors='replace') - except: - pass - return text - - # Capture output - old_stdout = sys.stdout - old_stderr = sys.stderr - stdout_buffer = BufferedTextIO() - stderr_buffer = BufferedTextIO() - - sys.stdout = stdout_buffer - sys.stderr = stderr_buffer - - try: - self.shell.execute(f'cat {path}') - content = stdout_buffer.getvalue() - finally: - sys.stdout = old_stdout - sys.stderr = old_stderr - - return web.json_response({'content': content}) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_write_file(self, request): - """Write file contents""" - try: - data = await request.json() - path = data.get('path', '') - content = data.get('content', '') - - if not path: - return web.json_response( - {'error': 'Path is required'}, - status=400 - ) - - # Write file using filesystem API directly - try: - # Convert content to bytes - content_bytes = content.encode('utf-8') - - # Write to filesystem - self.shell.filesystem.write_file(path, content_bytes) - - return web.json_response({'success': True}) - except Exception as e: - return web.json_response( - {'error': f'Failed to write file: {str(e)}'}, - status=500 - ) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_download_file(self, request): - """Download file contents (for binary/non-text files)""" - path = request.query.get('path', '') - - if not path: - return web.json_response( - {'error': 'Path is required'}, - status=400 - ) - - try: - # Read file using filesystem API - content = self.shell.filesystem.read_file(path) - - # Get filename from path - filename = path.split('/')[-1] - - # Determine content type based on extension - import mimetypes - content_type, _ = mimetypes.guess_type(filename) - if content_type is None: - content_type = 'application/octet-stream' - - # Return file with download headers - return web.Response( - body=content, - headers={ - 'Content-Type': content_type, - 'Content-Disposition': f'attachment; filename="{filename}"' - } - ) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_copy_file(self, request): - """Copy file from source to target""" - try: - data = await request.json() - source_path = data.get('sourcePath', '') - target_path = data.get('targetPath', '') - - if not source_path or not target_path: - return web.json_response( - {'error': 'Source and target paths are required'}, - status=400 - ) - - # Read source file - content = self.shell.filesystem.read_file(source_path) - - # Write to target - self.shell.filesystem.write_file(target_path, content) - - return web.json_response({'success': True}) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_delete_file(self, request): - """Delete a file or directory""" - try: - data = await request.json() - path = data.get('path', '') - - if not path: - return web.json_response( - {'error': 'Path is required'}, - status=400 - ) - - # Delete using filesystem API - self.shell.filesystem.delete_file(path) - - return web.json_response({'success': True}) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_upload_file(self, request): - """Upload a file to the filesystem""" - try: - reader = await request.multipart() - - directory = '/' - file_data = None - filename = None - - # Read multipart data - async for field in reader: - if field.name == 'directory': - directory = await field.text() - elif field.name == 'file': - filename = field.filename - file_data = await field.read() - - if not file_data or not filename: - return web.json_response( - {'error': 'No file provided'}, - status=400 - ) - - # Construct target path - target_path = f"{directory.rstrip('/')}/{filename}" if directory != '/' else f"/{filename}" - - # Write file to filesystem - self.shell.filesystem.write_file(target_path, file_data) - - return web.json_response({ - 'success': True, - 'path': target_path - }) - - except Exception as e: - return web.json_response( - {'error': str(e)}, - status=500 - ) - - async def handle_websocket(self, request): - """Handle WebSocket connection for terminal""" - ws = web.WebSocketResponse() - await ws.prepare(request) - - # Create a new shell session for this WebSocket - session = ShellSession(self.shell, ws) - session_id = id(ws) - self.sessions[session_id] = session - - try: - # Send welcome message - from . import __version__ - await session.send(f'\x1b[32magfs-shell v{__version__} ready\x1b[0m\r\n') - await session.send(f'\x1b[90mConnected to {self.shell.server_url}\x1b[0m\r\n') - await session.send('$ ') - - # Handle incoming messages - async for msg in ws: - if msg.type == web.WSMsgType.TEXT: - try: - data = json.loads(msg.data) - msg_type = data.get('type') - - if msg_type == 'command': - command = data.get('data', '') - - if command.strip(): - # Execute command - exit_code = await session.handle_command(command) - - # Send new prompt - await session.send('$ ') - else: - # Empty command, just show prompt - await session.send('$ ') - - elif msg_type == 'explorer': - # Get directory listing for Explorer - path = data.get('path', '/') - - try: - entries = self.shell.filesystem.list_directory(path) - - # Format entries - files = [] - for entry in entries: - name = entry.get('name', '') - if name and name not in ['.', '..']: - # AGFS API returns 'isDir' instead of 'type' - is_dir = entry.get('isDir', False) - file_type = 'directory' if is_dir else 'file' - - files.append({ - 'name': name, - 'path': f"{path.rstrip('/')}/{name}" if path != '/' else f"/{name}", - 'type': file_type, - 'size': entry.get('size', 0), - 'mtime': entry.get('modTime', ''), - }) - - # Sort: directories first, then by name - files.sort(key=lambda x: (x['type'] != 'directory', x['name'].lower())) - - await ws.send_json({ - 'type': 'explorer', - 'path': path, - 'files': files - }) - except Exception as e: - await ws.send_json({ - 'type': 'explorer', - 'path': path, - 'error': str(e), - 'files': [] - }) - - elif msg_type == 'complete': - # Tab completion request - text = data.get('text', '') - line = data.get('line', '') - cursor_pos = data.get('cursor_pos', len(line)) - - try: - completions = session.get_completions(text, line, cursor_pos) - # Send completions back to client - await ws.send_json({ - 'type': 'completions', - 'completions': completions - }) - except Exception as e: - # Send empty completions on error - await ws.send_json({ - 'type': 'completions', - 'completions': [] - }) - - elif msg_type == 'resize': - # Terminal resize event (can be used for future enhancements) - pass - - except json.JSONDecodeError: - # If not JSON, treat as raw command - await session.send('\x1b[31mInvalid message format\x1b[0m\r\n$ ') - except Exception as e: - await session.send(f'\x1b[31mError: {str(e)}\x1b[0m\r\n$ ') - - elif msg.type == web.WSMsgType.ERROR: - print(f'WebSocket error: {ws.exception()}') - - finally: - # Clean up session - if session_id in self.sessions: - del self.sessions[session_id] - - return ws - - async def handle_static(self, request): - """Serve static files""" - # Serve the built React app - webapp_dir = Path(__file__).parent.parent / 'webapp' / 'dist' - - path = request.match_info.get('path', 'index.html') - if path == '': - path = 'index.html' - - file_path = webapp_dir / path - - # Handle client-side routing - serve index.html for non-existent paths - if not file_path.exists() or file_path.is_dir(): - file_path = webapp_dir / 'index.html' - - if file_path.exists() and file_path.is_file(): - return web.FileResponse(file_path) - else: - return web.Response(text='Not found', status=404) - - async def init_app(self): - """Initialize the web application""" - self.app = web.Application() - - # Setup CORS - cors = aiohttp_cors.setup(self.app, defaults={ - "*": aiohttp_cors.ResourceOptions( - allow_credentials=True, - expose_headers="*", - allow_headers="*", - ) - }) - - # API routes - api_routes = [ - self.app.router.add_get('/api/files/list', self.handle_list_files), - self.app.router.add_get('/api/files/read', self.handle_read_file), - self.app.router.add_post('/api/files/write', self.handle_write_file), - self.app.router.add_get('/api/files/download', self.handle_download_file), - self.app.router.add_post('/api/files/copy', self.handle_copy_file), - self.app.router.add_post('/api/files/delete', self.handle_delete_file), - self.app.router.add_post('/api/files/upload', self.handle_upload_file), - ] - - # WebSocket route (no CORS needed) - self.app.router.add_get('/ws/terminal', self.handle_websocket) - - # Static files (serve React app) - self.app.router.add_get('/', self.handle_static) - self.app.router.add_get('/{path:.*}', self.handle_static) - - # Configure CORS for API routes only - for route in api_routes: - cors.add(route) - - async def start(self): - """Start the web server""" - await self.init_app() - - self.runner = web.AppRunner(self.app) - await self.runner.setup() - - site = web.TCPSite(self.runner, self.host, self.port) - await site.start() - - print(f'\n\x1b[32mWeb app server running at http://{self.host}:{self.port}\x1b[0m\n') - - async def stop(self): - """Stop the web server""" - # Close all WebSocket connections - for session in list(self.sessions.values()): - if session.ws and not session.ws.closed: - await session.ws.close() - - if self.runner: - await self.runner.cleanup() - - -def run_server(shell, host='localhost', port=3000): - """Run the web app server""" - server = WebAppServer(shell, host, port) - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - try: - loop.run_until_complete(server.start()) - loop.run_forever() - except KeyboardInterrupt: - print('\n\x1b[33mShutting down...\x1b[0m') - finally: - loop.run_until_complete(server.stop()) - loop.close() diff --git a/third_party/agfs/agfs-shell/build.py b/third_party/agfs/agfs-shell/build.py deleted file mode 100755 index 680319bde..000000000 --- a/third_party/agfs/agfs-shell/build.py +++ /dev/null @@ -1,327 +0,0 @@ -#!/usr/bin/env python3 -""" -Build script for agfs-shell -Creates a portable distribution with embedded dependencies using virtual environment -Requires Python 3.8+ on target system, but includes all dependencies -""" -import os -import sys -import subprocess -import shutil -from pathlib import Path -from datetime import datetime - -def get_git_hash(): - """Get current git commit hash""" - try: - result = subprocess.run( - ["git", "rev-parse", "--short", "HEAD"], - capture_output=True, - text=True, - check=True - ) - return result.stdout.strip() - except: - return "unknown" - -def inject_version_info(script_dir): - """Inject git hash and build date into __init__.py""" - try: - version_file = script_dir / "agfs_shell" / "__init__.py" - - if not version_file.exists(): - print(f"Warning: Version file not found at {version_file}") - return - - git_hash = get_git_hash() - build_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # Read current version file - with open(version_file, 'r') as f: - content = f.read() - - # Add build info if not present - if '__git_hash__' not in content: - # Find the version line and add build info after it - lines = content.split('\n') - new_lines = [] - for line in lines: - new_lines.append(line) - if line.startswith('__version__'): - new_lines.append(f'__git_hash__ = "{git_hash}"') - new_lines.append(f'__build_date__ = "{build_date}"') - content = '\n'.join(new_lines) - else: - # Replace placeholders - import re - content = re.sub(r'__git_hash__ = ".*?"', f'__git_hash__ = "{git_hash}"', content) - content = re.sub(r'__build_date__ = ".*?"', f'__build_date__ = "{build_date}"', content) - - # Write back - with open(version_file, 'w') as f: - f.write(content) - - print(f"Injected version info: git={git_hash}, date={build_date}") - except Exception as e: - print(f"Error injecting version info: {e}") - raise - -def restore_version_file(script_dir): - """Restore __init__.py to dev state""" - try: - version_file = script_dir / "agfs_shell" / "__init__.py" - - if not version_file.exists(): - print(f"Warning: Version file not found at {version_file}") - return - - with open(version_file, 'r') as f: - content = f.read() - - # Remove build info lines or restore to dev placeholders - lines = content.split('\n') - new_lines = [] - for line in lines: - if '__git_hash__' in line or '__build_date__' in line: - continue - new_lines.append(line) - - with open(version_file, 'w') as f: - f.write('\n'.join(new_lines)) - - print("Restored version file to dev state") - except Exception as e: - print(f"Warning: Failed to restore version file: {e}") - # Don't raise here - we don't want to fail the build if restore fails - - -def main(): - # Get the directory containing this script - script_dir = Path(__file__).parent.absolute() - dist_dir = script_dir / "dist" - portable_dir = dist_dir / "agfs-shell-portable" - - print("Building portable agfs-shell distribution...") - - # Clean previous builds - if portable_dir.exists(): - shutil.rmtree(portable_dir) - portable_dir.mkdir(parents=True, exist_ok=True) - - try: - # Check if uv is available - has_uv = shutil.which("uv") is not None - - if not has_uv: - print("Error: uv is required for building") - print("Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh") - sys.exit(1) - - # Inject version information (after all prerequisite checks) - inject_version_info(script_dir) - - print("Installing dependencies to portable directory...") - # Install dependencies directly to a lib directory (no venv) - lib_dir = portable_dir / "lib" - - # First copy pyagfs SDK source directly (bypass uv's editable mode) - pyagfs_src_dir = script_dir.parent / "agfs-sdk" / "python" / "pyagfs" - if pyagfs_src_dir.exists(): - print(f"Copying local pyagfs from {pyagfs_src_dir}...") - pyagfs_dest_dir = lib_dir / "pyagfs" - shutil.copytree(pyagfs_src_dir, pyagfs_dest_dir) - - # Also install pyagfs dependencies - subprocess.check_call([ - "uv", "pip", "install", - "--target", str(lib_dir), - "--python", sys.executable, - "--upgrade", # Always upgrade to latest versions - "requests>=2.31.0" # Install pyagfs's dependencies with their transitive deps - ], cwd=str(script_dir)) - else: - print(f"Warning: pyagfs SDK not found at {pyagfs_src_dir}") - - # Then install agfs-shell and remaining dependencies - subprocess.check_call([ - "uv", "pip", "install", - "--target", str(lib_dir), - "--python", sys.executable, - "--no-deps", # Don't install dependencies, we'll do it separately - str(script_dir) - ], cwd=str(script_dir)) - - # Install all agfs-shell dependencies from pyproject.toml (excluding pyagfs which we already copied) - # Including webapp dependencies for portable package - # Use --upgrade to ensure we always get the latest versions - subprocess.check_call([ - "uv", "pip", "install", - "--target", str(lib_dir), - "--python", sys.executable, - "--upgrade", # Always upgrade to latest versions - "--reinstall", # Force reinstall to ensure clean state - "rich", - "jq", - "llm", # Required for LLM integration - "pyyaml", # Required for YAML parsing - "aiohttp>=3.9.0", # Webapp dependency - "aiohttp-cors>=0.7.0" # Webapp dependency - ], cwd=str(script_dir)) - - # Build and copy webapp - print("Building webapp...") - webapp_src_dir = script_dir / "webapp" - webapp_dist_dir = webapp_src_dir / "dist" - - # Check if npm is available - has_npm = shutil.which("npm") is not None - - if has_npm and webapp_src_dir.exists(): - try: - # Install webapp dependencies - print(" Installing webapp dependencies...") - subprocess.check_call( - ["npm", "install"], - cwd=str(webapp_src_dir), - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE - ) - - # Build webapp - print(" Building webapp frontend...") - subprocess.check_call( - ["npm", "run", "build"], - cwd=str(webapp_src_dir), - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE - ) - - # Copy built webapp to portable package - if webapp_dist_dir.exists(): - target_webapp_dir = lib_dir / "webapp" / "dist" - print(f" Copying webapp to {target_webapp_dir}...") - shutil.copytree(webapp_dist_dir, target_webapp_dir) - print(" ✓ Webapp built and copied successfully") - else: - print(" Warning: Webapp build output not found at", webapp_dist_dir) - except subprocess.CalledProcessError as e: - print(f" Warning: Failed to build webapp: {e}") - print(" The portable package will not include webapp support") - else: - if not has_npm: - print(" Warning: npm not found, skipping webapp build") - if not webapp_src_dir.exists(): - print(" Warning: webapp directory not found, skipping webapp build") - print(" The portable package will not include webapp support") - - # Create launcher script - print("Creating launcher scripts...") - launcher_script = portable_dir / "agfs-shell" - launcher_content = '''#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""AGFS Shell Launcher -Portable launcher script that uses system Python but bundled dependencies -""" -import sys -import os - -# Resolve the real path of this script (follow symlinks) -script_path = os.path.realpath(__file__) -script_dir = os.path.dirname(script_path) - -# Add lib directory to Python path -lib_dir = os.path.join(script_dir, 'lib') -sys.path.insert(0, lib_dir) - -# Run the CLI -from agfs_shell.cli import main - -if __name__ == '__main__': - main() -''' - with open(launcher_script, 'w') as f: - f.write(launcher_content) - os.chmod(launcher_script, 0o755) - - # Create Windows launcher - launcher_bat = portable_dir / "agfs-shell.bat" - with open(launcher_bat, 'w') as f: - f.write("""@echo off -REM AGFS Shell Launcher for Windows -python "%~dp0agfs-shell" %%* -""") - - # Create README - readme = portable_dir / "README.txt" - version_info = get_version_string() - with open(readme, 'w') as f: - f.write(f"""AGFS Shell - Portable Distribution -=================================== - -Version: {version_info} -Built: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} -Git: {get_git_hash()} - -This is a portable distribution of agfs-shell that includes all dependencies -in a bundled library directory, including web app support. - -Requirements: -- Python 3.8 or higher on the system -- No additional Python packages needed -- Node.js/npm is NOT required (webapp is pre-built) - -Usage: - ./agfs-shell # Start interactive shell - ./agfs-shell --webapp # Start web app (default: localhost:3000) - ./agfs-shell --webapp --webapp-port 8000 # Use custom port - -Installation: - You can move this entire directory anywhere and run ./agfs-shell directly. - Optionally, add it to your PATH or symlink ./agfs-shell to /usr/local/bin/agfs-shell - -Environment Variables: - AGFS_API_URL - Override default API endpoint (default: http://localhost:8080/api/v1) - -Examples: - # Start with remote server - AGFS_API_URL=http://remote-server:8080/api/v1 ./agfs-shell - - # Start web app on all interfaces - ./agfs-shell --webapp --webapp-host 0.0.0.0 --webapp-port 3000 -""") - - # Calculate size - total_size = sum(f.stat().st_size for f in portable_dir.rglob('*') if f.is_file()) - - print(f"\nBuild successful!") - print(f"Portable directory: {portable_dir}") - print(f"Size: {total_size / 1024 / 1024:.2f} MB") - print(f"\nUsage:") - print(f" {portable_dir}/agfs-shell") - print(f"\nTo install, run: make install") - - finally: - # Always restore version file to dev state - restore_version_file(script_dir) - -def get_version_string(): - """Get version string for README""" - try: - # Read from agfs_shell/__init__.py - version_file = Path(__file__).parent / "agfs_shell" / "__init__.py" - namespace = {} - with open(version_file) as f: - exec(f.read(), namespace) - - version = namespace.get('__version__', '0.1.0') - git_hash = namespace.get('__git_hash__', 'dev') - build_date = namespace.get('__build_date__', 'dev') - - if git_hash == 'dev': - return f"{version} (dev)" - return f"{version} (git: {git_hash}, built: {build_date})" - except: - return "0.1.0" - -if __name__ == "__main__": - main() diff --git a/third_party/agfs/agfs-shell/examples/enqueue_task.as b/third_party/agfs/agfs-shell/examples/enqueue_task.as deleted file mode 100755 index 0f9deb348..000000000 --- a/third_party/agfs/agfs-shell/examples/enqueue_task.as +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env agfs - -# Enqueue Task Script -# -# Usage: -# ./enqueue_task.as [queue_path] -# -# Arguments: -# task_data - Task content (required) -# queue_path - Queue path (default: /queue/mem/task_queue) -# -# Examples: -# ./enqueue_task.as "process file.txt" -# ./enqueue_task.as "send email" /queue/mem/email_queue - -# Check arguments -if [ -z "$1" ]; then - echo "Usage: $0 [queue_path]" - echo "" - echo "Examples:" - echo " $0 \"process file.txt\"" - echo " $0 \"run backup\" /queue/mem/backup_queue" - exit 1 -fi - -TASK_DATA=$1 - -# Queue path -if [ -n "$2" ]; then - QUEUE_PATH=$2 -else - QUEUE_PATH=/queue/mem/task_queue -fi - -ENQUEUE_FILE=$QUEUE_PATH/enqueue -SIZE_FILE=$QUEUE_PATH/size - -# Ensure queue exists -mkdir $QUEUE_PATH - -# Enqueue -echo "$TASK_DATA" > $ENQUEUE_FILE - -echo "Task enqueued successfully!" -echo " Queue: $QUEUE_PATH" -echo " Data: $TASK_DATA" - -# Show current queue size -size=$(cat $SIZE_FILE) -echo " Queue size: $size" diff --git a/third_party/agfs/agfs-shell/examples/task_queue_worker.as b/third_party/agfs/agfs-shell/examples/task_queue_worker.as deleted file mode 100755 index 3f486d8d6..000000000 --- a/third_party/agfs/agfs-shell/examples/task_queue_worker.as +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env agfs - -# Task Queue Worker - Process tasks from QueueFS in a loop -# -# Usage: -# ./task_queue_worker.as [queue_path] -# -# Example: -# ./task_queue_worker.as /queue/mem/task_queue - -# ============================================================================= -# Configuration -# ============================================================================= - -# Queue path (can be overridden via argument) -if [ -n "$1" ]; then - QUEUE_PATH=$1 -else - QUEUE_PATH=/queue/mem/task_queue -fi - -# Queue operation file paths -DEQUEUE_FILE=$QUEUE_PATH/dequeue -SIZE_FILE=$QUEUE_PATH/size - -# Poll interval in seconds -POLL_INTERVAL=2 - -echo "==========================================" -echo " Task Queue Worker" -echo "==========================================" -echo "Queue Path: $QUEUE_PATH" -echo "==========================================" -echo "" - -# Initialize queue -echo "Initializing queue..." -mkdir $QUEUE_PATH - -# Task counter -task_count=0 - -# Main loop -while true; do - # Get queue size - size=$(cat $SIZE_FILE) - - if [ "$size" = "0" ]; then - echo "Queue empty, waiting ${POLL_INTERVAL}s..." - sleep $POLL_INTERVAL - continue - fi - - if [ -z "$size" ]; then - echo "Queue empty, waiting ${POLL_INTERVAL}s..." - sleep $POLL_INTERVAL - continue - fi - - echo "Queue size: $size" - - # Dequeue task - task_json=$(cat $DEQUEUE_FILE) - - if [ -z "$task_json" ]; then - continue - fi - - task_count=$((task_count + 1)) - - echo "" - echo "==========================================" - echo "Task #$task_count received" - echo "==========================================" - - # Print raw JSON - echo "Raw: $task_json" - echo "----------------------------------------" - - # ========================================================== - # Add your task processing logic here - # You can use $task_json variable to get task data - # ========================================================== - echo "Processing task #$task_count..." - sleep 1 - echo "Task completed!" - - echo "==========================================" - echo "" -done diff --git a/third_party/agfs/agfs-shell/pyproject.toml b/third_party/agfs/agfs-shell/pyproject.toml deleted file mode 100644 index be8f84789..000000000 --- a/third_party/agfs/agfs-shell/pyproject.toml +++ /dev/null @@ -1,41 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "agfs-shell" -dynamic = ["version"] -description = "Experimental shell with Unix-style pipeline support" -readme = "README.md" -requires-python = ">=3.8" -authors = [ - { name = "agfs authors" } -] -dependencies = [ - "pyagfs>=1.4.0", - "rich", - "jq", - "llm", - "pyyaml", -] - -[project.optional-dependencies] -webapp = [ - "aiohttp>=3.9.0", - "aiohttp-cors>=0.7.0", -] - -[tool.uv.sources] -pyagfs = { path = "../agfs-sdk/python", editable = true } - -[project.scripts] -agfs-shell = "agfs_shell.cli:main" - -[tool.uv] -dev-dependencies = [] - -[tool.hatch.build.targets.wheel] -packages = ["agfs_shell"] - -[tool.hatch.version] -path = "agfs_shell/__init__.py" diff --git a/third_party/agfs/agfs-shell/scripts/test_functions.as b/third_party/agfs/agfs-shell/scripts/test_functions.as deleted file mode 100755 index 9aebcc481..000000000 --- a/third_party/agfs/agfs-shell/scripts/test_functions.as +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env uv run agfs-shell - -# Test suite for working function features -# This only tests features that are currently supported - -echo "=== Function Feature Tests (Currently Supported) ===" -echo "" - -# Test 1: Basic function definition and call -echo "Test 1: Basic Function Call" -greet() { - echo "Hello, $1!" -} - -greet Alice -greet Bob -echo "✓ Basic function calls work" -echo "" - -# Test 2: Positional parameters -echo "Test 2: Positional Parameters" -show_params() { - echo "Function: $0" - echo "Count: $#" - echo "First: $1" - echo "Second: $2" - echo "All: $@" -} - -show_params apple banana cherry -echo "✓ Positional parameters work" -echo "" - -# Test 3: Local variables -echo "Test 3: Local Variables" -x=100 -test_local() { - local x=10 - echo "Inside function: x=$x" - x=20 - echo "Modified local: x=$x" -} - -echo "Before function: x=$x" -test_local -echo "After function: x=$x" -echo "✓ Local variables work (global unchanged)" -echo "" - -# Test 4: Arithmetic with local variables -echo "Test 4: Arithmetic with Local Variables" -calc() { - local a=$1 - local b=$2 - local sum=$((a + b)) - local product=$((a * b)) - echo "Sum: $sum" - echo "Product: $product" -} - -calc 5 3 -echo "✓ Arithmetic with local variables works" -echo "" - -# Test 5: Return values (only test success case in script mode) -echo "Test 5: Return Values" -check_success() { - if [ $1 -eq 42 ]; then - return 0 - fi - return 1 -} - -check_success 42 -echo "check_success(42): $? (expected: 0)" - -# Note: Testing return 1 would stop script execution -# In interactive mode, you can test: check_success 0; echo $? -echo "✓ Return values work" -echo "" - -# Test 6: If statements in functions -echo "Test 6: If Statements" -check_positive() { - if [ $1 -gt 0 ]; then - echo "Positive" - elif [ $1 -lt 0 ]; then - echo "Negative" - else - echo "Zero" - fi -} - -check_positive 5 -check_positive -3 -check_positive 0 -echo "✓ If statements in functions work" -echo "" - -# Test 7: For loops in functions -echo "Test 7: For Loops" -print_list() { - for item in $@; do - echo " - $item" - done -} - -print_list apple banana cherry -echo "✓ For loops in functions work" -echo "" - -# Test 8: Function calling another function -echo "Test 8: Function Calling Function" -inner() { - echo "Inner function called with: $1" -} - -outer() { - echo "Outer function calling inner..." - inner "from outer" -} - -outer -echo "✓ Functions can call other functions" -echo "" - -# Test 9: Multiple local variables -echo "Test 9: Multiple Local Variables" -multi_local() { - local a=1 - local b=2 - local c=3 - echo "a=$a, b=$b, c=$c" - local sum=$((a + b + c)) - echo "Sum: $sum" -} - -multi_local -echo "✓ Multiple local variables work" -echo "" - -# Test 10: Functions with continue in loops -echo "Test 10: Continue in Loops" -test_continue() { - for i in 1 2 3 4 5; do - if [ $i -eq 3 ]; then - continue - fi - echo " $i" - done -} - -echo "Continue test:" -test_continue -echo "✓ Continue works in function loops" -echo "" - -# Note: Break also works but causes non-zero exit in current implementation -# when loop exits early. This is a known behavior. - -echo "=== All Supported Features Work! ===" diff --git a/third_party/agfs/agfs-shell/tests/test_builtins.py b/third_party/agfs/agfs-shell/tests/test_builtins.py deleted file mode 100644 index 24407cd0a..000000000 --- a/third_party/agfs/agfs-shell/tests/test_builtins.py +++ /dev/null @@ -1,403 +0,0 @@ -import unittest -import tempfile -import os -from unittest.mock import Mock, MagicMock -from agfs_shell.builtins import BUILTINS -from agfs_shell.process import Process -from agfs_shell.streams import InputStream, OutputStream, ErrorStream - -class TestBuiltins(unittest.TestCase): - def create_process(self, command, args, input_data=""): - stdin = InputStream.from_string(input_data) - stdout = OutputStream.to_buffer() - stderr = ErrorStream.to_buffer() - return Process(command, args, stdin, stdout, stderr) - - def test_echo(self): - cmd = BUILTINS['echo'] - - # Test basic echo - proc = self.create_process("echo", ["hello", "world"]) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"hello world\n") - - # Test empty echo - proc = self.create_process("echo", []) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"\n") - - def test_cat_stdin(self): - cmd = BUILTINS['cat'] - input_data = "line1\nline2\n" - proc = self.create_process("cat", [], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), input_data.encode('utf-8')) - - def test_cat_file(self): - cmd = BUILTINS['cat'] - with tempfile.TemporaryDirectory() as tmpdir: - filename = os.path.join(tmpdir, "test.txt") - with open(filename, "w") as f: - f.write("file content") - - proc = self.create_process("cat", [filename]) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"file content") - - def test_grep(self): - cmd = BUILTINS['grep'] - input_data = "apple\nbanana\ncherry\n" - - # Match found - proc = self.create_process("grep", ["pp"], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"apple\n") - - # No match - proc = self.create_process("grep", ["xyz"], input_data) - self.assertEqual(cmd(proc), 1) - self.assertEqual(proc.get_stdout(), b"") - - # Missing pattern - proc = self.create_process("grep", [], input_data) - self.assertEqual(cmd(proc), 2) - self.assertIn(b"missing pattern", proc.get_stderr()) - - def test_wc(self): - cmd = BUILTINS['wc'] - input_data = "one two\nthree\n" - # 2 lines, 3 words, 14 bytes - - # Default (all) - proc = self.create_process("wc", [], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"2 3 14\n") - - # Lines only - proc = self.create_process("wc", ["-l"], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"2\n") - - def test_head(self): - cmd = BUILTINS['head'] - input_data = "\n".join([f"line{i}" for i in range(20)]) + "\n" - - # Default 10 lines - proc = self.create_process("head", [], input_data) - self.assertEqual(cmd(proc), 0) - output = proc.get_stdout().decode('utf-8').splitlines() - self.assertEqual(len(output), 10) - self.assertEqual(output[0], "line0") - self.assertEqual(output[-1], "line9") - - # Custom lines - proc = self.create_process("head", ["-n", "5"], input_data) - self.assertEqual(cmd(proc), 0) - output = proc.get_stdout().decode('utf-8').splitlines() - self.assertEqual(len(output), 5) - - def test_tail(self): - cmd = BUILTINS['tail'] - input_data = "\n".join([f"line{i}" for i in range(20)]) + "\n" - - # Default 10 lines - proc = self.create_process("tail", [], input_data) - self.assertEqual(cmd(proc), 0) - output = proc.get_stdout().decode('utf-8').splitlines() - self.assertEqual(len(output), 10) - self.assertEqual(output[0], "line10") - self.assertEqual(output[-1], "line19") - - def test_sort(self): - cmd = BUILTINS['sort'] - input_data = "c\na\nb\n" - - # Normal sort - proc = self.create_process("sort", [], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"a\nb\nc\n") - - # Reverse sort - proc = self.create_process("sort", ["-r"], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"c\nb\na\n") - - def test_uniq(self): - cmd = BUILTINS['uniq'] - input_data = "a\na\nb\nb\nc\n" - - proc = self.create_process("uniq", [], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"a\nb\nc\n") - - def test_tr(self): - cmd = BUILTINS['tr'] - input_data = "hello" - - # Translate - proc = self.create_process("tr", ["el", "ip"], input_data) - self.assertEqual(cmd(proc), 0) - self.assertEqual(proc.get_stdout(), b"hippo") - - # Error cases - proc = self.create_process("tr", ["a"], input_data) - self.assertEqual(cmd(proc), 1) - self.assertIn(b"missing operand", proc.get_stderr()) - - def test_ls_multiple_files(self): - """Test ls command with multiple file arguments (like from glob expansion)""" - cmd = BUILTINS['ls'] - - # Create a mock filesystem - mock_fs = Mock() - - # Mock get_file_info to return file info for each path - def mock_get_file_info(path): - # Simulate file metadata - if path.endswith('.txt'): - return { - 'name': os.path.basename(path), - 'isDir': False, - 'size': 100, - 'modTime': '2025-11-23T12:00:00Z', - 'mode': 'rw-r--r--' - } - else: - raise Exception(f"No such file: {path}") - - mock_fs.get_file_info = mock_get_file_info - - # Test with multiple file paths (simulating glob expansion like 'ls *.txt') - proc = self.create_process("ls", [ - "/test/file1.txt", - "/test/file2.txt", - "/test/file3.txt" - ]) - proc.filesystem = mock_fs - - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Check output contains all files - output = proc.get_stdout().decode('utf-8') - self.assertIn('file1.txt', output) - self.assertIn('file2.txt', output) - self.assertIn('file3.txt', output) - - # Verify each file listed once - self.assertEqual(output.count('file1.txt'), 1) - self.assertEqual(output.count('file2.txt'), 1) - self.assertEqual(output.count('file3.txt'), 1) - - def test_ls_mixed_files_and_dirs(self): - """Test ls command with mix of files and directories""" - cmd = BUILTINS['ls'] - - # Create a mock filesystem - mock_fs = Mock() - - # Mock get_file_info to return file/dir info - def mock_get_file_info(path): - if path == "/test/dir1": - return { - 'name': 'dir1', - 'isDir': True, - 'size': 0, - 'modTime': '2025-11-23T12:00:00Z' - } - elif path.endswith('.txt'): - return { - 'name': os.path.basename(path), - 'isDir': False, - 'size': 100, - 'modTime': '2025-11-23T12:00:00Z' - } - else: - raise Exception(f"No such file: {path}") - - # Mock list_directory for the directory - def mock_list_directory(path): - if path == "/test/dir1": - return [ - {'name': 'subfile1.txt', 'isDir': False, 'size': 50}, - {'name': 'subfile2.txt', 'isDir': False, 'size': 60} - ] - else: - raise Exception(f"Not a directory: {path}") - - mock_fs.get_file_info = mock_get_file_info - mock_fs.list_directory = mock_list_directory - - # Test with mix of file and directory - proc = self.create_process("ls", [ - "/test/file1.txt", - "/test/dir1" - ]) - proc.filesystem = mock_fs - - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Check output - output = proc.get_stdout().decode('utf-8') - # File should be listed - self.assertIn('file1.txt', output) - # Directory contents should be listed - self.assertIn('subfile1.txt', output) - self.assertIn('subfile2.txt', output) - - def test_rm_with_glob_pattern(self): - """Test rm command with glob pattern (simulating shell glob expansion)""" - cmd = BUILTINS['rm'] - - # Create a mock filesystem - mock_fs = Mock() - mock_client = Mock() - mock_fs.client = mock_client - - # Track which files were deleted - deleted_files = [] - - def mock_rm(path, recursive=False): - deleted_files.append((path, recursive)) - - mock_client.rm = mock_rm - - # Test rm with multiple files (simulating glob expansion of '23_11_2025*') - # This simulates what should happen when the shell expands the glob pattern - proc = self.create_process("rm", [ - "/test/23_11_2025_11_43_05.wav", - "/test/23_11_2025_11_43_36.wav", - "/test/23_11_2025_11_44_11.wav" - ]) - proc.filesystem = mock_fs - - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Verify all files were deleted - self.assertEqual(len(deleted_files), 3) - self.assertIn(('/test/23_11_2025_11_43_05.wav', False), deleted_files) - self.assertIn(('/test/23_11_2025_11_43_36.wav', False), deleted_files) - self.assertIn(('/test/23_11_2025_11_44_11.wav', False), deleted_files) - - def test_cp_with_glob_pattern(self): - """Test cp command with glob pattern (simulating shell glob expansion)""" - cmd = BUILTINS['cp'] - - # Create a mock filesystem - mock_fs = Mock() - - # Track which files were copied - copied_files = [] - - def mock_read_file(path, stream=False): - return b"file contents" - - def mock_write_file(path, data, append=False): - copied_files.append((path, data)) - - def mock_get_file_info(path): - # Mock /dest/ as a directory - if path == '/dest' or path == '/dest/': - return {'name': 'dest', 'isDir': True, 'size': 0} - # Mock source files as regular files - return {'name': os.path.basename(path), 'isDir': False, 'size': 100} - - mock_fs.read_file = mock_read_file - mock_fs.write_file = mock_write_file - mock_fs.get_file_info = mock_get_file_info - - # Test cp with multiple source files (simulating glob expansion like 'cp *.txt /dest/') - proc = self.create_process("cp", [ - "/test/file1.txt", - "/test/file2.txt", - "/test/file3.txt", - "/dest/" - ]) - proc.filesystem = mock_fs - proc.cwd = "/test" - - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Verify all files were copied - self.assertEqual(len(copied_files), 3) - - # Check that the destination paths are correct - copied_paths = [path for path, _ in copied_files] - self.assertIn('/dest/file1.txt', copied_paths) - self.assertIn('/dest/file2.txt', copied_paths) - self.assertIn('/dest/file3.txt', copied_paths) - - def test_cp_with_local_prefix(self): - """Test cp command with local: prefix to ensure it doesn't get path-resolved""" - import tempfile - import shutil - - cmd = BUILTINS['cp'] - - # Create a temporary directory for testing - temp_dir = tempfile.mkdtemp() - - try: - # Create a mock filesystem - mock_fs = Mock() - - def mock_read_file(path, stream=False): - if stream: - # Return an iterable of chunks - return [b"file contents chunk 1", b"file contents chunk 2"] - return b"file contents" - - def mock_get_file_info(path): - return {'name': os.path.basename(path), 'isDir': False, 'size': 100} - - mock_fs.read_file = mock_read_file - mock_fs.get_file_info = mock_get_file_info - - # Test download: cp local:./ - # The local:./ should be resolved to current directory, not treated as AGFS path - proc = self.create_process("cp", [ - "/s3fs/test/file.wav", - f"local:{temp_dir}/" - ]) - proc.filesystem = mock_fs - proc.cwd = "/s3fs/aws/dongxu/omi-recording/raw/2025/11/23/16" - - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Verify file was downloaded to local directory - downloaded_file = os.path.join(temp_dir, "file.wav") - self.assertTrue(os.path.exists(downloaded_file)) - - finally: - # Clean up temp directory - shutil.rmtree(temp_dir) - - def test_date(self): - """Test date command calls system date and returns output""" - cmd = BUILTINS['date'] - - # Test basic date command (no arguments) - proc = self.create_process("date", []) - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Output should contain date/time information (not empty) - output = proc.get_stdout().decode('utf-8') - self.assertTrue(len(output) > 0) - - # Test date with format argument - proc = self.create_process("date", ["+%Y"]) - exit_code = cmd(proc) - self.assertEqual(exit_code, 0) - - # Should return current year (4 digits + newline) - output = proc.get_stdout().decode('utf-8').strip() - self.assertTrue(output.isdigit()) - self.assertEqual(len(output), 4) - -if __name__ == '__main__': - unittest.main() diff --git a/third_party/agfs/agfs-shell/tests/test_parser.py b/third_party/agfs/agfs-shell/tests/test_parser.py deleted file mode 100644 index 8b41eff0a..000000000 --- a/third_party/agfs/agfs-shell/tests/test_parser.py +++ /dev/null @@ -1,91 +0,0 @@ -import unittest -from agfs_shell.parser import CommandParser - -class TestCommandParser(unittest.TestCase): - def test_parse_pipeline_simple(self): - cmd = "ls -l" - expected = [("ls", ["-l"])] - self.assertEqual(CommandParser.parse_pipeline(cmd), expected) - - def test_parse_pipeline_multiple(self): - cmd = "cat file.txt | grep pattern | wc -l" - expected = [ - ("cat", ["file.txt"]), - ("grep", ["pattern"]), - ("wc", ["-l"]) - ] - self.assertEqual(CommandParser.parse_pipeline(cmd), expected) - - def test_parse_pipeline_quoted(self): - cmd = 'echo "hello world" | grep "world"' - expected = [ - ("echo", ["hello world"]), - ("grep", ["world"]) - ] - self.assertEqual(CommandParser.parse_pipeline(cmd), expected) - - def test_parse_pipeline_empty(self): - self.assertEqual(CommandParser.parse_pipeline(""), []) - self.assertEqual(CommandParser.parse_pipeline(" "), []) - - def test_parse_redirection_stdin(self): - cmd = "cat < input.txt" - cleaned, redirs = CommandParser.parse_redirection(cmd) - self.assertEqual(cleaned, "cat") - self.assertEqual(redirs["stdin"], "input.txt") - - def test_parse_redirection_stdout(self): - cmd = "ls > output.txt" - cleaned, redirs = CommandParser.parse_redirection(cmd) - self.assertEqual(cleaned, "ls") - self.assertEqual(redirs["stdout"], "output.txt") - self.assertEqual(redirs["stdout_mode"], "write") - - def test_parse_redirection_append(self): - cmd = "echo hello >> log.txt" - cleaned, redirs = CommandParser.parse_redirection(cmd) - self.assertEqual(cleaned, "echo hello") - self.assertEqual(redirs["stdout"], "log.txt") - self.assertEqual(redirs["stdout_mode"], "append") - - def test_parse_redirection_stderr(self): - cmd = "cmd 2> error.log" - cleaned, redirs = CommandParser.parse_redirection(cmd) - self.assertEqual(cleaned, "cmd") - self.assertEqual(redirs["stderr"], "error.log") - self.assertEqual(redirs["stderr_mode"], "write") - - def test_quote_arg(self): - self.assertEqual(CommandParser.quote_arg("simple"), "simple") - self.assertEqual(CommandParser.quote_arg("hello world"), "'hello world'") - self.assertEqual(CommandParser.quote_arg("foo|bar"), "'foo|bar'") - - def test_unquote_arg(self): - self.assertEqual(CommandParser.unquote_arg("'hello'"), "hello") - self.assertEqual(CommandParser.unquote_arg('"world"'), "world") - self.assertEqual(CommandParser.unquote_arg("simple"), "simple") - - def test_parse_filenames_with_spaces(self): - """Test parsing filenames with spaces using quotes""" - # Double quotes - cmd = 'rm "Ed Huang - 2024 US filing authorization forms.PDF"' - commands, _ = CommandParser.parse_command_line(cmd) - self.assertEqual(commands, [('rm', ['Ed Huang - 2024 US filing authorization forms.PDF'])]) - - # Single quotes - cmd = "rm 'Ed Huang - 2024 US filing authorization forms.PDF'" - commands, _ = CommandParser.parse_command_line(cmd) - self.assertEqual(commands, [('rm', ['Ed Huang - 2024 US filing authorization forms.PDF'])]) - - # Multiple files with spaces - cmd = 'rm "file 1.txt" "file 2.txt" normal.txt' - commands, _ = CommandParser.parse_command_line(cmd) - self.assertEqual(commands, [('rm', ['file 1.txt', 'file 2.txt', 'normal.txt'])]) - - # ls with filename containing spaces - cmd = 'ls -l "2. 【清洁版】INSTRUMENT OF TRANSFER.doc"' - commands, _ = CommandParser.parse_command_line(cmd) - self.assertEqual(commands, [('ls', ['-l', '2. 【清洁版】INSTRUMENT OF TRANSFER.doc'])]) - -if __name__ == '__main__': - unittest.main() diff --git a/third_party/agfs/agfs-shell/tests/test_pipeline.py b/third_party/agfs/agfs-shell/tests/test_pipeline.py deleted file mode 100644 index 59049c711..000000000 --- a/third_party/agfs/agfs-shell/tests/test_pipeline.py +++ /dev/null @@ -1,75 +0,0 @@ -import unittest -from agfs_shell.pipeline import Pipeline -from agfs_shell.process import Process -from agfs_shell.streams import InputStream, OutputStream, ErrorStream - -class TestPipeline(unittest.TestCase): - def create_mock_process(self, name, output=None, exit_code=0): - def executor(proc): - if output: - proc.stdout.write(output) - # Read stdin to simulate consumption - proc.stdin.read() - return exit_code - - return Process(name, [], executor=executor) - - def create_echo_process(self, text): - def executor(proc): - proc.stdout.write(text) - return 0 - return Process("echo", [text], executor=executor) - - def create_cat_process(self): - def executor(proc): - data = proc.stdin.read() - proc.stdout.write(data) - return 0 - return Process("cat", [], executor=executor) - - def test_single_process(self): - p1 = self.create_mock_process("p1", output="hello", exit_code=0) - pipeline = Pipeline([p1]) - - self.assertEqual(pipeline.execute(), 0) - self.assertEqual(pipeline.get_stdout(), b"hello") - self.assertEqual(pipeline.get_exit_code(), 0) - - def test_pipeline_flow(self): - # echo "hello" | cat - p1 = self.create_echo_process("hello") - p2 = self.create_cat_process() - - pipeline = Pipeline([p1, p2]) - - self.assertEqual(pipeline.execute(), 0) - self.assertEqual(pipeline.get_stdout(), b"hello") - - def test_pipeline_chain(self): - # echo "hello" | cat | cat - p1 = self.create_echo_process("hello") - p2 = self.create_cat_process() - p3 = self.create_cat_process() - - pipeline = Pipeline([p1, p2, p3]) - - self.assertEqual(pipeline.execute(), 0) - self.assertEqual(pipeline.get_stdout(), b"hello") - - def test_exit_code(self): - # p1 (ok) | p2 (fail) - p1 = self.create_mock_process("p1", exit_code=0) - p2 = self.create_mock_process("p2", exit_code=1) - - pipeline = Pipeline([p1, p2]) - - self.assertEqual(pipeline.execute(), 1) - self.assertEqual(pipeline.get_exit_code(), 1) - - def test_empty_pipeline(self): - pipeline = Pipeline([]) - self.assertEqual(pipeline.execute(), 0) - self.assertEqual(pipeline.get_stdout(), b"") - -if __name__ == '__main__': - unittest.main() diff --git a/third_party/agfs/agfs-shell/uv.lock b/third_party/agfs/agfs-shell/uv.lock deleted file mode 100644 index 0cedc4d0c..000000000 --- a/third_party/agfs/agfs-shell/uv.lock +++ /dev/null @@ -1,2879 +0,0 @@ -version = 1 -revision = 1 -requires-python = ">=3.8" -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] - -[[package]] -name = "agfs-shell" -source = { editable = "." } -dependencies = [ - { name = "jq" }, - { name = "llm", version = "0.16", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "llm", version = "0.27.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pyagfs" }, - { name = "pyyaml" }, - { name = "rich" }, -] - -[package.optional-dependencies] -webapp = [ - { name = "aiohttp", version = "3.10.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "aiohttp", version = "3.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "aiohttp-cors", version = "0.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "aiohttp-cors", version = "0.8.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiohttp", marker = "extra == 'webapp'", specifier = ">=3.9.0" }, - { name = "aiohttp-cors", marker = "extra == 'webapp'", specifier = ">=0.7.0" }, - { name = "jq" }, - { name = "llm" }, - { name = "pyagfs", editable = "../agfs-sdk/python" }, - { name = "pyyaml" }, - { name = "rich" }, -] -provides-extras = ["webapp"] - -[package.metadata.requires-dev] -dev = [] - -[[package]] -name = "aiohappyeyeballs" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/7f/55/e4373e888fdacb15563ef6fa9fa8c8252476ea071e96fb46defac9f18bf2/aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745", size = 21977 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/74/fbb6559de3607b3300b9be3cc64e97548d55678e44623db17820dbd20002/aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8", size = 14756 }, -] - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265 }, -] - -[[package]] -name = "aiohttp" -version = "3.10.11" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "aiohappyeyeballs", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "aiosignal", version = "1.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "async-timeout", marker = "python_full_version < '3.9'" }, - { name = "attrs", version = "25.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "frozenlist", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "multidict", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "yarl", version = "1.15.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/25/a8/8e2ba36c6e3278d62e0c88aa42bb92ddbef092ac363b390dab4421da5cf5/aiohttp-3.10.11.tar.gz", hash = "sha256:9dc2b8f3dcab2e39e0fa309c8da50c3b55e6f34ab25f1a71d3288f24924d33a7", size = 7551886 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/c7/575f9e82d7ef13cb1b45b9db8a5b8fadb35107fb12e33809356ae0155223/aiohttp-3.10.11-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5077b1a5f40ffa3ba1f40d537d3bec4383988ee51fbba6b74aa8fb1bc466599e", size = 588218 }, - { url = "https://files.pythonhosted.org/packages/12/7b/a800dadbd9a47b7f921bfddcd531371371f39b9cd05786c3638bfe2e1175/aiohttp-3.10.11-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8d6a14a4d93b5b3c2891fca94fa9d41b2322a68194422bef0dd5ec1e57d7d298", size = 400815 }, - { url = "https://files.pythonhosted.org/packages/cb/28/7dbd53ab10b0ded397feed914880f39ce075bd39393b8dfc322909754a0a/aiohttp-3.10.11-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffbfde2443696345e23a3c597049b1dd43049bb65337837574205e7368472177", size = 392099 }, - { url = "https://files.pythonhosted.org/packages/6a/2e/c6390f49e67911711c2229740e261c501685fe7201f7f918d6ff2fd1cfb0/aiohttp-3.10.11-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20b3d9e416774d41813bc02fdc0663379c01817b0874b932b81c7f777f67b217", size = 1224854 }, - { url = "https://files.pythonhosted.org/packages/69/68/c96afae129201bff4edbece52b3e1abf3a8af57529a42700669458b00b9f/aiohttp-3.10.11-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2b943011b45ee6bf74b22245c6faab736363678e910504dd7531a58c76c9015a", size = 1259641 }, - { url = "https://files.pythonhosted.org/packages/63/89/bedd01456442747946114a8c2f30ff1b23d3b2ea0c03709f854c4f354a5a/aiohttp-3.10.11-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48bc1d924490f0d0b3658fe5c4b081a4d56ebb58af80a6729d4bd13ea569797a", size = 1295412 }, - { url = "https://files.pythonhosted.org/packages/9b/4d/942198e2939efe7bfa484781590f082135e9931b8bcafb4bba62cf2d8f2f/aiohttp-3.10.11-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e12eb3f4b1f72aaaf6acd27d045753b18101524f72ae071ae1c91c1cd44ef115", size = 1218311 }, - { url = "https://files.pythonhosted.org/packages/a3/5b/8127022912f1fa72dfc39cf37c36f83e0b56afc3b93594b1cf377b6e4ffc/aiohttp-3.10.11-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f14ebc419a568c2eff3c1ed35f634435c24ead2fe19c07426af41e7adb68713a", size = 1189448 }, - { url = "https://files.pythonhosted.org/packages/af/12/752878033c8feab3362c0890a4d24e9895921729a53491f6f6fad64d3287/aiohttp-3.10.11-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:72b191cdf35a518bfc7ca87d770d30941decc5aaf897ec8b484eb5cc8c7706f3", size = 1186484 }, - { url = "https://files.pythonhosted.org/packages/61/24/1d91c304fca47d5e5002ca23abab9b2196ac79d5c531258e048195b435b2/aiohttp-3.10.11-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5ab2328a61fdc86424ee540d0aeb8b73bbcad7351fb7cf7a6546fc0bcffa0038", size = 1183864 }, - { url = "https://files.pythonhosted.org/packages/c1/70/022d28b898314dac4cb5dd52ead2a372563c8590b1eaab9c5ed017eefb1e/aiohttp-3.10.11-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aa93063d4af05c49276cf14e419550a3f45258b6b9d1f16403e777f1addf4519", size = 1241460 }, - { url = "https://files.pythonhosted.org/packages/c3/15/2b43853330f82acf180602de0f68be62a2838d25d03d2ed40fecbe82479e/aiohttp-3.10.11-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:30283f9d0ce420363c24c5c2421e71a738a2155f10adbb1a11a4d4d6d2715cfc", size = 1258521 }, - { url = "https://files.pythonhosted.org/packages/28/38/9ef2076cb06dcc155e7f02275f5da403a3e7c9327b6b075e999f0eb73613/aiohttp-3.10.11-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e5358addc8044ee49143c546d2182c15b4ac3a60be01c3209374ace05af5733d", size = 1207329 }, - { url = "https://files.pythonhosted.org/packages/c2/5f/c5329d67a2c83d8ae17a84e11dec14da5773520913bfc191caaf4cd57e50/aiohttp-3.10.11-cp310-cp310-win32.whl", hash = "sha256:e1ffa713d3ea7cdcd4aea9cddccab41edf6882fa9552940344c44e59652e1120", size = 363835 }, - { url = "https://files.pythonhosted.org/packages/0f/c6/ca5d70eea2fdbe283dbc1e7d30649a1a5371b2a2a9150db192446f645789/aiohttp-3.10.11-cp310-cp310-win_amd64.whl", hash = "sha256:778cbd01f18ff78b5dd23c77eb82987ee4ba23408cbed233009fd570dda7e674", size = 382169 }, - { url = "https://files.pythonhosted.org/packages/73/96/221ec59bc38395a6c205cbe8bf72c114ce92694b58abc8c3c6b7250efa7f/aiohttp-3.10.11-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:80ff08556c7f59a7972b1e8919f62e9c069c33566a6d28586771711e0eea4f07", size = 587742 }, - { url = "https://files.pythonhosted.org/packages/24/17/4e606c969b19de5c31a09b946bd4c37e30c5288ca91d4790aa915518846e/aiohttp-3.10.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c8f96e9ee19f04c4914e4e7a42a60861066d3e1abf05c726f38d9d0a466e695", size = 400357 }, - { url = "https://files.pythonhosted.org/packages/a2/e5/433f59b87ba69736e446824710dd7f26fcd05b24c6647cb1e76554ea5d02/aiohttp-3.10.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fb8601394d537da9221947b5d6e62b064c9a43e88a1ecd7414d21a1a6fba9c24", size = 392099 }, - { url = "https://files.pythonhosted.org/packages/d2/a3/3be340f5063970bb9e47f065ee8151edab639d9c2dce0d9605a325ab035d/aiohttp-3.10.11-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ea224cf7bc2d8856d6971cea73b1d50c9c51d36971faf1abc169a0d5f85a382", size = 1300367 }, - { url = "https://files.pythonhosted.org/packages/ba/7d/a3043918466cbee9429792ebe795f92f70eeb40aee4ccbca14c38ee8fa4d/aiohttp-3.10.11-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:db9503f79e12d5d80b3efd4d01312853565c05367493379df76d2674af881caa", size = 1339448 }, - { url = "https://files.pythonhosted.org/packages/2c/60/192b378bd9d1ae67716b71ae63c3e97c48b134aad7675915a10853a0b7de/aiohttp-3.10.11-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0f449a50cc33f0384f633894d8d3cd020e3ccef81879c6e6245c3c375c448625", size = 1374875 }, - { url = "https://files.pythonhosted.org/packages/e0/d7/cd58bd17f5277d9cc32ecdbb0481ca02c52fc066412de413aa01268dc9b4/aiohttp-3.10.11-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82052be3e6d9e0c123499127782a01a2b224b8af8c62ab46b3f6197035ad94e9", size = 1285626 }, - { url = "https://files.pythonhosted.org/packages/bb/b2/da4953643b7dcdcd29cc99f98209f3653bf02023d95ce8a8fd57ffba0f15/aiohttp-3.10.11-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:20063c7acf1eec550c8eb098deb5ed9e1bb0521613b03bb93644b810986027ac", size = 1246120 }, - { url = "https://files.pythonhosted.org/packages/6c/22/1217b3c773055f0cb172e3b7108274a74c0fe9900c716362727303931cbb/aiohttp-3.10.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:489cced07a4c11488f47aab1f00d0c572506883f877af100a38f1fedaa884c3a", size = 1265177 }, - { url = "https://files.pythonhosted.org/packages/63/5e/3827ad7e61544ed1e73e4fdea7bb87ea35ac59a362d7eb301feb5e859780/aiohttp-3.10.11-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ea9b3bab329aeaa603ed3bf605f1e2a6f36496ad7e0e1aa42025f368ee2dc07b", size = 1257238 }, - { url = "https://files.pythonhosted.org/packages/53/31/951f78751d403da6086b662760e6e8b08201b0dcf5357969f48261b4d0e1/aiohttp-3.10.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ca117819d8ad113413016cb29774b3f6d99ad23c220069789fc050267b786c16", size = 1315944 }, - { url = "https://files.pythonhosted.org/packages/0d/79/06ef7a2a69880649261818b135b245de5a4e89fed5a6987c8645428563fc/aiohttp-3.10.11-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2dfb612dcbe70fb7cdcf3499e8d483079b89749c857a8f6e80263b021745c730", size = 1332065 }, - { url = "https://files.pythonhosted.org/packages/10/39/a273857c2d0bbf2152a4201fbf776931c2dac74aa399c6683ed4c286d1d1/aiohttp-3.10.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9b615d3da0d60e7d53c62e22b4fd1c70f4ae5993a44687b011ea3a2e49051b8", size = 1291882 }, - { url = "https://files.pythonhosted.org/packages/49/39/7aa387f88403febc96e0494101763afaa14d342109329a01b413b2bac075/aiohttp-3.10.11-cp311-cp311-win32.whl", hash = "sha256:29103f9099b6068bbdf44d6a3d090e0a0b2be6d3c9f16a070dd9d0d910ec08f9", size = 363409 }, - { url = "https://files.pythonhosted.org/packages/6f/e9/8eb3dc095ce48499d867ad461d02f1491686b79ad92e4fad4df582f6be7b/aiohttp-3.10.11-cp311-cp311-win_amd64.whl", hash = "sha256:236b28ceb79532da85d59aa9b9bf873b364e27a0acb2ceaba475dc61cffb6f3f", size = 382644 }, - { url = "https://files.pythonhosted.org/packages/01/16/077057ef3bd684dbf9a8273a5299e182a8d07b4b252503712ff8b5364fd1/aiohttp-3.10.11-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:7480519f70e32bfb101d71fb9a1f330fbd291655a4c1c922232a48c458c52710", size = 584830 }, - { url = "https://files.pythonhosted.org/packages/2c/cf/348b93deb9597c61a51b6682e81f7c7d79290249e886022ef0705d858d90/aiohttp-3.10.11-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f65267266c9aeb2287a6622ee2bb39490292552f9fbf851baabc04c9f84e048d", size = 397090 }, - { url = "https://files.pythonhosted.org/packages/70/bf/903df5cd739dfaf5b827b3d8c9d68ff4fcea16a0ca1aeb948c9da30f56c8/aiohttp-3.10.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7400a93d629a0608dc1d6c55f1e3d6e07f7375745aaa8bd7f085571e4d1cee97", size = 392361 }, - { url = "https://files.pythonhosted.org/packages/fb/97/e4792675448a2ac5bd56f377a095233b805dd1315235c940c8ba5624e3cb/aiohttp-3.10.11-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f34b97e4b11b8d4eb2c3a4f975be626cc8af99ff479da7de49ac2c6d02d35725", size = 1309839 }, - { url = "https://files.pythonhosted.org/packages/96/d0/ba19b1260da6fbbda4d5b1550d8a53ba3518868f2c143d672aedfdbc6172/aiohttp-3.10.11-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e7b825da878464a252ccff2958838f9caa82f32a8dbc334eb9b34a026e2c636", size = 1348116 }, - { url = "https://files.pythonhosted.org/packages/b3/b9/15100ee7113a2638bfdc91aecc54641609a92a7ce4fe533ebeaa8d43ff93/aiohttp-3.10.11-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f9f92a344c50b9667827da308473005f34767b6a2a60d9acff56ae94f895f385", size = 1391402 }, - { url = "https://files.pythonhosted.org/packages/c5/36/831522618ac0dcd0b28f327afd18df7fb6bbf3eaf302f912a40e87714846/aiohttp-3.10.11-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f1ab987a27b83c5268a17218463c2ec08dbb754195113867a27b166cd6087", size = 1304239 }, - { url = "https://files.pythonhosted.org/packages/60/9f/b7230d0c48b076500ae57adb717aa0656432acd3d8febb1183dedfaa4e75/aiohttp-3.10.11-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1dc0f4ca54842173d03322793ebcf2c8cc2d34ae91cc762478e295d8e361e03f", size = 1256565 }, - { url = "https://files.pythonhosted.org/packages/63/c2/35c7b4699f4830b3b0a5c3d5619df16dca8052ae8b488e66065902d559f6/aiohttp-3.10.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7ce6a51469bfaacff146e59e7fb61c9c23006495d11cc24c514a455032bcfa03", size = 1269285 }, - { url = "https://files.pythonhosted.org/packages/51/48/bc20ea753909bdeb09f9065260aefa7453e3a57f6a51f56f5216adc1a5e7/aiohttp-3.10.11-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:aad3cd91d484d065ede16f3cf15408254e2469e3f613b241a1db552c5eb7ab7d", size = 1276716 }, - { url = "https://files.pythonhosted.org/packages/0c/7b/a8708616b3810f55ead66f8e189afa9474795760473aea734bbea536cd64/aiohttp-3.10.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f4df4b8ca97f658c880fb4b90b1d1ec528315d4030af1ec763247ebfd33d8b9a", size = 1315023 }, - { url = "https://files.pythonhosted.org/packages/2a/d6/dfe9134a921e05b01661a127a37b7d157db93428905450e32f9898eef27d/aiohttp-3.10.11-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e4e18a0a2d03531edbc06c366954e40a3f8d2a88d2b936bbe78a0c75a3aab3e", size = 1342735 }, - { url = "https://files.pythonhosted.org/packages/ca/1a/3bd7f18e3909eabd57e5d17ecdbf5ea4c5828d91341e3676a07de7c76312/aiohttp-3.10.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ce66780fa1a20e45bc753cda2a149daa6dbf1561fc1289fa0c308391c7bc0a4", size = 1302618 }, - { url = "https://files.pythonhosted.org/packages/cf/51/d063133781cda48cfdd1e11fc8ef45ab3912b446feba41556385b3ae5087/aiohttp-3.10.11-cp312-cp312-win32.whl", hash = "sha256:a919c8957695ea4c0e7a3e8d16494e3477b86f33067478f43106921c2fef15bb", size = 360497 }, - { url = "https://files.pythonhosted.org/packages/55/4e/f29def9ed39826fe8f85955f2e42fe5cc0cbe3ebb53c97087f225368702e/aiohttp-3.10.11-cp312-cp312-win_amd64.whl", hash = "sha256:b5e29706e6389a2283a91611c91bf24f218962717c8f3b4e528ef529d112ee27", size = 380577 }, - { url = "https://files.pythonhosted.org/packages/1f/63/654c185dfe3cf5d4a0d35b6ee49ee6ca91922c694eaa90732e1ba4b40ef1/aiohttp-3.10.11-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:703938e22434d7d14ec22f9f310559331f455018389222eed132808cd8f44127", size = 577381 }, - { url = "https://files.pythonhosted.org/packages/4e/c4/ee9c350acb202ba2eb0c44b0f84376b05477e870444192a9f70e06844c28/aiohttp-3.10.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9bc50b63648840854e00084c2b43035a62e033cb9b06d8c22b409d56eb098413", size = 393289 }, - { url = "https://files.pythonhosted.org/packages/3d/7c/30d161a7e3b208cef1b922eacf2bbb8578b7e5a62266a6a2245a1dd044dc/aiohttp-3.10.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f0463bf8b0754bc744e1feb61590706823795041e63edf30118a6f0bf577461", size = 388859 }, - { url = "https://files.pythonhosted.org/packages/79/10/8d050e04be447d3d39e5a4a910fa289d930120cebe1b893096bd3ee29063/aiohttp-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6c6dec398ac5a87cb3a407b068e1106b20ef001c344e34154616183fe684288", size = 1280983 }, - { url = "https://files.pythonhosted.org/packages/31/b3/977eca40afe643dcfa6b8d8bb9a93f4cba1d8ed1ead22c92056b08855c7a/aiohttp-3.10.11-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcaf2d79104d53d4dcf934f7ce76d3d155302d07dae24dff6c9fffd217568067", size = 1317132 }, - { url = "https://files.pythonhosted.org/packages/1a/43/b5ee8e697ed0f96a2b3d80b3058fa7590cda508e9cd256274246ba1cf37a/aiohttp-3.10.11-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25fd5470922091b5a9aeeb7e75be609e16b4fba81cdeaf12981393fb240dd10e", size = 1362630 }, - { url = "https://files.pythonhosted.org/packages/28/20/3ae8e993b2990fa722987222dea74d6bac9331e2f530d086f309b4aa8847/aiohttp-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbde2ca67230923a42161b1f408c3992ae6e0be782dca0c44cb3206bf330dee1", size = 1276865 }, - { url = "https://files.pythonhosted.org/packages/02/08/1afb0ab7dcff63333b683e998e751aa2547d1ff897b577d2244b00e6fe38/aiohttp-3.10.11-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:249c8ff8d26a8b41a0f12f9df804e7c685ca35a207e2410adbd3e924217b9006", size = 1230448 }, - { url = "https://files.pythonhosted.org/packages/c6/fd/ccd0ff842c62128d164ec09e3dd810208a84d79cd402358a3038ae91f3e9/aiohttp-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:878ca6a931ee8c486a8f7b432b65431d095c522cbeb34892bee5be97b3481d0f", size = 1244626 }, - { url = "https://files.pythonhosted.org/packages/9f/75/30e9537ab41ed7cb062338d8df7c4afb0a715b3551cd69fc4ea61cfa5a95/aiohttp-3.10.11-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8663f7777ce775f0413324be0d96d9730959b2ca73d9b7e2c2c90539139cbdd6", size = 1243608 }, - { url = "https://files.pythonhosted.org/packages/c2/e0/3e7a62d99b9080793affddc12a82b11c9bc1312916ad849700d2bddf9786/aiohttp-3.10.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6cd3f10b01f0c31481fba8d302b61603a2acb37b9d30e1d14e0f5a58b7b18a31", size = 1286158 }, - { url = "https://files.pythonhosted.org/packages/71/b8/df67886802e71e976996ed9324eb7dc379e53a7d972314e9c7fe3f6ac6bc/aiohttp-3.10.11-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4e8d8aad9402d3aa02fdc5ca2fe68bcb9fdfe1f77b40b10410a94c7f408b664d", size = 1313636 }, - { url = "https://files.pythonhosted.org/packages/3c/3b/aea9c3e70ff4e030f46902df28b4cdf486695f4d78fd9c6698827e2bafab/aiohttp-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:38e3c4f80196b4f6c3a85d134a534a56f52da9cb8d8e7af1b79a32eefee73a00", size = 1273772 }, - { url = "https://files.pythonhosted.org/packages/e9/9e/4b4c5705270d1c4ee146516ad288af720798d957ba46504aaf99b86e85d9/aiohttp-3.10.11-cp313-cp313-win32.whl", hash = "sha256:fc31820cfc3b2863c6e95e14fcf815dc7afe52480b4dc03393c4873bb5599f71", size = 358679 }, - { url = "https://files.pythonhosted.org/packages/28/1d/18ef37549901db94717d4389eb7be807acbfbdeab48a73ff2993fc909118/aiohttp-3.10.11-cp313-cp313-win_amd64.whl", hash = "sha256:4996ff1345704ffdd6d75fb06ed175938c133425af616142e7187f28dc75f14e", size = 378073 }, - { url = "https://files.pythonhosted.org/packages/dd/f2/59165bee7bba0b0634525834c622f152a30715a1d8280f6291a0cb86b1e6/aiohttp-3.10.11-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:74baf1a7d948b3d640badeac333af581a367ab916b37e44cf90a0334157cdfd2", size = 592135 }, - { url = "https://files.pythonhosted.org/packages/2e/0e/b3555c504745af66efbf89d16811148ff12932b86fad529d115538fe2739/aiohttp-3.10.11-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:473aebc3b871646e1940c05268d451f2543a1d209f47035b594b9d4e91ce8339", size = 402913 }, - { url = "https://files.pythonhosted.org/packages/31/bb/2890a3c77126758ef58536ca9f7476a12ba2021e0cd074108fb99b8c8747/aiohttp-3.10.11-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c2f746a6968c54ab2186574e15c3f14f3e7f67aef12b761e043b33b89c5b5f95", size = 394013 }, - { url = "https://files.pythonhosted.org/packages/74/82/0ab5199b473558846d72901a714b6afeb6f6a6a6a4c3c629e2c107418afd/aiohttp-3.10.11-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d110cabad8360ffa0dec8f6ec60e43286e9d251e77db4763a87dcfe55b4adb92", size = 1255578 }, - { url = "https://files.pythonhosted.org/packages/f8/b2/f232477dd3c0e95693a903c4815bfb8d831f6a1a67e27ad14d30a774eeda/aiohttp-3.10.11-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0099c7d5d7afff4202a0c670e5b723f7718810000b4abcbc96b064129e64bc7", size = 1298780 }, - { url = "https://files.pythonhosted.org/packages/34/8c/11972235a6b53d5b69098f2ee6629ff8f99cd9592dcaa620c7868deb5673/aiohttp-3.10.11-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0316e624b754dbbf8c872b62fe6dcb395ef20c70e59890dfa0de9eafccd2849d", size = 1336093 }, - { url = "https://files.pythonhosted.org/packages/03/be/7ad9a6cd2312221cf7b6837d8e2d8e4660fbd4f9f15bccf79ef857f41f4d/aiohttp-3.10.11-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a5f7ab8baf13314e6b2485965cbacb94afff1e93466ac4d06a47a81c50f9cca", size = 1250296 }, - { url = "https://files.pythonhosted.org/packages/bb/8d/a3885a582d9fc481bccb155d082f83a7a846942e36e4a4bba061e3d6b95e/aiohttp-3.10.11-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c891011e76041e6508cbfc469dd1a8ea09bc24e87e4c204e05f150c4c455a5fa", size = 1215020 }, - { url = "https://files.pythonhosted.org/packages/bb/e7/09a1736b7264316dc3738492d9b559f2a54b985660f21d76095c9890a62e/aiohttp-3.10.11-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:9208299251370ee815473270c52cd3f7069ee9ed348d941d574d1457d2c73e8b", size = 1210591 }, - { url = "https://files.pythonhosted.org/packages/58/b1/ee684631f6af98065d49ac8416db7a8e74ea33e1378bc75952ab0522342f/aiohttp-3.10.11-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:459f0f32c8356e8125f45eeff0ecf2b1cb6db1551304972702f34cd9e6c44658", size = 1211255 }, - { url = "https://files.pythonhosted.org/packages/8f/55/e21e312fd6c581f244dd2ed077ccb784aade07c19416a6316b1453f02c4e/aiohttp-3.10.11-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:14cdc8c1810bbd4b4b9f142eeee23cda528ae4e57ea0923551a9af4820980e39", size = 1278114 }, - { url = "https://files.pythonhosted.org/packages/d8/7f/ff6df0e90df6759693f52720ebedbfa10982d97aa1fd02c6ca917a6399ea/aiohttp-3.10.11-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:971aa438a29701d4b34e4943e91b5e984c3ae6ccbf80dd9efaffb01bd0b243a9", size = 1292714 }, - { url = "https://files.pythonhosted.org/packages/3a/45/63f35367dfffae41e7abd0603f92708b5b3655fda55c08388ac2c7fb127b/aiohttp-3.10.11-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:9a309c5de392dfe0f32ee57fa43ed8fc6ddf9985425e84bd51ed66bb16bce3a7", size = 1233734 }, - { url = "https://files.pythonhosted.org/packages/ec/ee/74b0696c0e84e06c43beab9302f353d97dc9f0cccd7ccf3ee648411b849b/aiohttp-3.10.11-cp38-cp38-win32.whl", hash = "sha256:9ec1628180241d906a0840b38f162a3215114b14541f1a8711c368a8739a9be4", size = 365350 }, - { url = "https://files.pythonhosted.org/packages/21/0c/74c895688db09a2852056abf32d128991ec2fb41e5f57a1fe0928e15151c/aiohttp-3.10.11-cp38-cp38-win_amd64.whl", hash = "sha256:9c6e0ffd52c929f985c7258f83185d17c76d4275ad22e90aa29f38e211aacbec", size = 384542 }, - { url = "https://files.pythonhosted.org/packages/cc/df/aa0d1548db818395a372b5f90e62072677ce786d6b19680c49dd4da3825f/aiohttp-3.10.11-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc493a2e5d8dc79b2df5bec9558425bcd39aff59fc949810cbd0832e294b106", size = 589833 }, - { url = "https://files.pythonhosted.org/packages/75/7c/d11145784b3fa29c0421a3883a4b91ee8c19acb40332b1d2e39f47be4e5b/aiohttp-3.10.11-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b3e70f24e7d0405be2348da9d5a7836936bf3a9b4fd210f8c37e8d48bc32eca6", size = 401685 }, - { url = "https://files.pythonhosted.org/packages/e2/67/1b5f93babeb060cb683d23104b243be1d6299fe6cd807dcb56cf67d2e62c/aiohttp-3.10.11-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:968b8fb2a5eee2770eda9c7b5581587ef9b96fbdf8dcabc6b446d35ccc69df01", size = 392957 }, - { url = "https://files.pythonhosted.org/packages/e1/4d/441df53aafd8dd97b8cfe9e467c641fa19cb5113e7601a7f77f2124518e0/aiohttp-3.10.11-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:deef4362af9493d1382ef86732ee2e4cbc0d7c005947bd54ad1a9a16dd59298e", size = 1229754 }, - { url = "https://files.pythonhosted.org/packages/4d/cc/f1397a2501b95cb94580de7051395e85af95a1e27aed1f8af73459ddfa22/aiohttp-3.10.11-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:686b03196976e327412a1b094f4120778c7c4b9cff9bce8d2fdfeca386b89829", size = 1266246 }, - { url = "https://files.pythonhosted.org/packages/c2/b5/7d33dae7630b4e9f90d634c6a90cb0923797e011b71cd9b10fe685aec3f6/aiohttp-3.10.11-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bf6d027d9d1d34e1c2e1645f18a6498c98d634f8e373395221121f1c258ace8", size = 1301720 }, - { url = "https://files.pythonhosted.org/packages/51/36/f917bcc63bc489aa3f534fa81efbf895fa5286745dcd8bbd0eb9dbc923a1/aiohttp-3.10.11-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:099fd126bf960f96d34a760e747a629c27fb3634da5d05c7ef4d35ef4ea519fc", size = 1221527 }, - { url = "https://files.pythonhosted.org/packages/32/c2/1a303a072b4763d99d4b0664a3a8b952869e3fbb660d4239826bd0c56cc1/aiohttp-3.10.11-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c73c4d3dae0b4644bc21e3de546530531d6cdc88659cdeb6579cd627d3c206aa", size = 1192309 }, - { url = "https://files.pythonhosted.org/packages/62/ef/d62f705dc665382b78ef171e5ba2616c395220ac7c1f452f0d2dcad3f9f5/aiohttp-3.10.11-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0c5580f3c51eea91559db3facd45d72e7ec970b04528b4709b1f9c2555bd6d0b", size = 1189481 }, - { url = "https://files.pythonhosted.org/packages/40/22/3e3eb4f97e5c4f52ccd198512b583c0c9135aa4e989c7ade97023c4cd282/aiohttp-3.10.11-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fdf6429f0caabfd8a30c4e2eaecb547b3c340e4730ebfe25139779b9815ba138", size = 1187877 }, - { url = "https://files.pythonhosted.org/packages/b5/73/77475777fbe2b3efaceb49db2859f1a22c96fd5869d736e80375db05bbf4/aiohttp-3.10.11-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d97187de3c276263db3564bb9d9fad9e15b51ea10a371ffa5947a5ba93ad6777", size = 1246006 }, - { url = "https://files.pythonhosted.org/packages/ef/f7/5b060d19065473da91838b63d8fd4d20ef8426a7d905cc8f9cd11eabd780/aiohttp-3.10.11-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:0acafb350cfb2eba70eb5d271f55e08bd4502ec35e964e18ad3e7d34d71f7261", size = 1260403 }, - { url = "https://files.pythonhosted.org/packages/6c/ea/e9ad224815cd83c8dfda686d2bafa2cab5b93d7232e09470a8d2a158acde/aiohttp-3.10.11-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c13ed0c779911c7998a58e7848954bd4d63df3e3575f591e321b19a2aec8df9f", size = 1208643 }, - { url = "https://files.pythonhosted.org/packages/ba/c1/e1c6bba72f379adbd52958601a8642546ed0807964afba3b1b5b8cfb1bc0/aiohttp-3.10.11-cp39-cp39-win32.whl", hash = "sha256:22b7c540c55909140f63ab4f54ec2c20d2635c0289cdd8006da46f3327f971b9", size = 364419 }, - { url = "https://files.pythonhosted.org/packages/30/24/50862e06e86cd263c60661e00b9d2c8d7fdece4fe95454ed5aa21ecf8036/aiohttp-3.10.11-cp39-cp39-win_amd64.whl", hash = "sha256:7b26b1551e481012575dab8e3727b16fe7dd27eb2711d2e63ced7368756268fb", size = 382857 }, -] - -[[package]] -name = "aiohttp" -version = "3.13.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "aiohappyeyeballs", version = "2.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "aiosignal", version = "1.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "async-timeout", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "attrs", version = "25.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "frozenlist", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "multidict", version = "6.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "yarl", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/34/939730e66b716b76046dedfe0842995842fa906ccc4964bba414ff69e429/aiohttp-3.13.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2372b15a5f62ed37789a6b383ff7344fc5b9f243999b0cd9b629d8bc5f5b4155", size = 736471 }, - { url = "https://files.pythonhosted.org/packages/fd/cf/dcbdf2df7f6ca72b0bb4c0b4509701f2d8942cf54e29ca197389c214c07f/aiohttp-3.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e7f8659a48995edee7229522984bd1009c1213929c769c2daa80b40fe49a180c", size = 493985 }, - { url = "https://files.pythonhosted.org/packages/9d/87/71c8867e0a1d0882dcbc94af767784c3cb381c1c4db0943ab4aae4fed65e/aiohttp-3.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:939ced4a7add92296b0ad38892ce62b98c619288a081170695c6babe4f50e636", size = 489274 }, - { url = "https://files.pythonhosted.org/packages/38/0f/46c24e8dae237295eaadd113edd56dee96ef6462adf19b88592d44891dc5/aiohttp-3.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6315fb6977f1d0dd41a107c527fee2ed5ab0550b7d885bc15fee20ccb17891da", size = 1668171 }, - { url = "https://files.pythonhosted.org/packages/eb/c6/4cdfb4440d0e28483681a48f69841fa5e39366347d66ef808cbdadddb20e/aiohttp-3.13.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6e7352512f763f760baaed2637055c49134fd1d35b37c2dedfac35bfe5cf8725", size = 1636036 }, - { url = "https://files.pythonhosted.org/packages/84/37/8708cf678628216fb678ab327a4e1711c576d6673998f4f43e86e9ae90dd/aiohttp-3.13.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e09a0a06348a2dd73e7213353c90d709502d9786219f69b731f6caa0efeb46f5", size = 1727975 }, - { url = "https://files.pythonhosted.org/packages/e6/2e/3ebfe12fdcb9b5f66e8a0a42dffcd7636844c8a018f261efb2419f68220b/aiohttp-3.13.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a09a6d073fb5789456545bdee2474d14395792faa0527887f2f4ec1a486a59d3", size = 1815823 }, - { url = "https://files.pythonhosted.org/packages/a1/4f/ca2ef819488cbb41844c6cf92ca6dd15b9441e6207c58e5ae0e0fc8d70ad/aiohttp-3.13.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b59d13c443f8e049d9e94099c7e412e34610f1f49be0f230ec656a10692a5802", size = 1669374 }, - { url = "https://files.pythonhosted.org/packages/f8/fe/1fe2e1179a0d91ce09c99069684aab619bf2ccde9b20bd6ca44f8837203e/aiohttp-3.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:20db2d67985d71ca033443a1ba2001c4b5693fe09b0e29f6d9358a99d4d62a8a", size = 1555315 }, - { url = "https://files.pythonhosted.org/packages/5a/2b/f3781899b81c45d7cbc7140cddb8a3481c195e7cbff8e36374759d2ab5a5/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:960c2fc686ba27b535f9fd2b52d87ecd7e4fd1cf877f6a5cba8afb5b4a8bd204", size = 1639140 }, - { url = "https://files.pythonhosted.org/packages/72/27/c37e85cd3ece6f6c772e549bd5a253d0c122557b25855fb274224811e4f2/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6c00dbcf5f0d88796151e264a8eab23de2997c9303dd7c0bf622e23b24d3ce22", size = 1645496 }, - { url = "https://files.pythonhosted.org/packages/66/20/3af1ab663151bd3780b123e907761cdb86ec2c4e44b2d9b195ebc91fbe37/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fed38a5edb7945f4d1bcabe2fcd05db4f6ec7e0e82560088b754f7e08d93772d", size = 1697625 }, - { url = "https://files.pythonhosted.org/packages/95/eb/ae5cab15efa365e13d56b31b0d085a62600298bf398a7986f8388f73b598/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:b395bbca716c38bef3c764f187860e88c724b342c26275bc03e906142fc5964f", size = 1542025 }, - { url = "https://files.pythonhosted.org/packages/e9/2d/1683e8d67ec72d911397fe4e575688d2a9b8f6a6e03c8fdc9f3fd3d4c03f/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:204ffff2426c25dfda401ba08da85f9c59525cdc42bda26660463dd1cbcfec6f", size = 1714918 }, - { url = "https://files.pythonhosted.org/packages/99/a2/ffe8e0e1c57c5e542d47ffa1fcf95ef2b3ea573bf7c4d2ee877252431efc/aiohttp-3.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:05c4dd3c48fb5f15db31f57eb35374cb0c09afdde532e7fb70a75aede0ed30f6", size = 1656113 }, - { url = "https://files.pythonhosted.org/packages/0d/42/d511aff5c3a2b06c09d7d214f508a4ad8ac7799817f7c3d23e7336b5e896/aiohttp-3.13.2-cp310-cp310-win32.whl", hash = "sha256:e574a7d61cf10351d734bcddabbe15ede0eaa8a02070d85446875dc11189a251", size = 432290 }, - { url = "https://files.pythonhosted.org/packages/8b/ea/1c2eb7098b5bad4532994f2b7a8228d27674035c9b3234fe02c37469ef14/aiohttp-3.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:364f55663085d658b8462a1c3f17b2b84a5c2e1ba858e1b79bff7b2e24ad1514", size = 455075 }, - { url = "https://files.pythonhosted.org/packages/35/74/b321e7d7ca762638cdf8cdeceb39755d9c745aff7a64c8789be96ddf6e96/aiohttp-3.13.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4647d02df098f6434bafd7f32ad14942f05a9caa06c7016fdcc816f343997dd0", size = 743409 }, - { url = "https://files.pythonhosted.org/packages/99/3d/91524b905ec473beaf35158d17f82ef5a38033e5809fe8742e3657cdbb97/aiohttp-3.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e3403f24bcb9c3b29113611c3c16a2a447c3953ecf86b79775e7be06f7ae7ccb", size = 497006 }, - { url = "https://files.pythonhosted.org/packages/eb/d3/7f68bc02a67716fe80f063e19adbd80a642e30682ce74071269e17d2dba1/aiohttp-3.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:43dff14e35aba17e3d6d5ba628858fb8cb51e30f44724a2d2f0c75be492c55e9", size = 493195 }, - { url = "https://files.pythonhosted.org/packages/98/31/913f774a4708775433b7375c4f867d58ba58ead833af96c8af3621a0d243/aiohttp-3.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2a9ea08e8c58bb17655630198833109227dea914cd20be660f52215f6de5613", size = 1747759 }, - { url = "https://files.pythonhosted.org/packages/e8/63/04efe156f4326f31c7c4a97144f82132c3bb21859b7bb84748d452ccc17c/aiohttp-3.13.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53b07472f235eb80e826ad038c9d106c2f653584753f3ddab907c83f49eedead", size = 1704456 }, - { url = "https://files.pythonhosted.org/packages/8e/02/4e16154d8e0a9cf4ae76f692941fd52543bbb148f02f098ca73cab9b1c1b/aiohttp-3.13.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e736c93e9c274fce6419af4aac199984d866e55f8a4cec9114671d0ea9688780", size = 1807572 }, - { url = "https://files.pythonhosted.org/packages/34/58/b0583defb38689e7f06798f0285b1ffb3a6fb371f38363ce5fd772112724/aiohttp-3.13.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ff5e771f5dcbc81c64898c597a434f7682f2259e0cd666932a913d53d1341d1a", size = 1895954 }, - { url = "https://files.pythonhosted.org/packages/6b/f3/083907ee3437425b4e376aa58b2c915eb1a33703ec0dc30040f7ae3368c6/aiohttp-3.13.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3b6fb0c207cc661fa0bf8c66d8d9b657331ccc814f4719468af61034b478592", size = 1747092 }, - { url = "https://files.pythonhosted.org/packages/ac/61/98a47319b4e425cc134e05e5f3fc512bf9a04bf65aafd9fdcda5d57ec693/aiohttp-3.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:97a0895a8e840ab3520e2288db7cace3a1981300d48babeb50e7425609e2e0ab", size = 1606815 }, - { url = "https://files.pythonhosted.org/packages/97/4b/e78b854d82f66bb974189135d31fce265dee0f5344f64dd0d345158a5973/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9e8f8afb552297aca127c90cb840e9a1d4bfd6a10d7d8f2d9176e1acc69bad30", size = 1723789 }, - { url = "https://files.pythonhosted.org/packages/ed/fc/9d2ccc794fc9b9acd1379d625c3a8c64a45508b5091c546dea273a41929e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed2f9c7216e53c3df02264f25d824b079cc5914f9e2deba94155190ef648ee40", size = 1718104 }, - { url = "https://files.pythonhosted.org/packages/66/65/34564b8765ea5c7d79d23c9113135d1dd3609173da13084830f1507d56cf/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:99c5280a329d5fa18ef30fd10c793a190d996567667908bef8a7f81f8202b948", size = 1785584 }, - { url = "https://files.pythonhosted.org/packages/30/be/f6a7a426e02fc82781afd62016417b3948e2207426d90a0e478790d1c8a4/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2ca6ffef405fc9c09a746cb5d019c1672cd7f402542e379afc66b370833170cf", size = 1595126 }, - { url = "https://files.pythonhosted.org/packages/e5/c7/8e22d5d28f94f67d2af496f14a83b3c155d915d1fe53d94b66d425ec5b42/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:47f438b1a28e926c37632bff3c44df7d27c9b57aaf4e34b1def3c07111fdb782", size = 1800665 }, - { url = "https://files.pythonhosted.org/packages/d1/11/91133c8b68b1da9fc16555706aa7276fdf781ae2bb0876c838dd86b8116e/aiohttp-3.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9acda8604a57bb60544e4646a4615c1866ee6c04a8edef9b8ee6fd1d8fa2ddc8", size = 1739532 }, - { url = "https://files.pythonhosted.org/packages/17/6b/3747644d26a998774b21a616016620293ddefa4d63af6286f389aedac844/aiohttp-3.13.2-cp311-cp311-win32.whl", hash = "sha256:868e195e39b24aaa930b063c08bb0c17924899c16c672a28a65afded9c46c6ec", size = 431876 }, - { url = "https://files.pythonhosted.org/packages/c3/63/688462108c1a00eb9f05765331c107f95ae86f6b197b865d29e930b7e462/aiohttp-3.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:7fd19df530c292542636c2a9a85854fab93474396a52f1695e799186bbd7f24c", size = 456205 }, - { url = "https://files.pythonhosted.org/packages/29/9b/01f00e9856d0a73260e86dd8ed0c2234a466c5c1712ce1c281548df39777/aiohttp-3.13.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b1e56bab2e12b2b9ed300218c351ee2a3d8c8fdab5b1ec6193e11a817767e47b", size = 737623 }, - { url = "https://files.pythonhosted.org/packages/5a/1b/4be39c445e2b2bd0aab4ba736deb649fabf14f6757f405f0c9685019b9e9/aiohttp-3.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:364e25edaabd3d37b1db1f0cbcee8c73c9a3727bfa262b83e5e4cf3489a2a9dc", size = 492664 }, - { url = "https://files.pythonhosted.org/packages/28/66/d35dcfea8050e131cdd731dff36434390479b4045a8d0b9d7111b0a968f1/aiohttp-3.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c5c94825f744694c4b8db20b71dba9a257cd2ba8e010a803042123f3a25d50d7", size = 491808 }, - { url = "https://files.pythonhosted.org/packages/00/29/8e4609b93e10a853b65f8291e64985de66d4f5848c5637cddc70e98f01f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba2715d842ffa787be87cbfce150d5e88c87a98e0b62e0f5aa489169a393dbbb", size = 1738863 }, - { url = "https://files.pythonhosted.org/packages/9d/fa/4ebdf4adcc0def75ced1a0d2d227577cd7b1b85beb7edad85fcc87693c75/aiohttp-3.13.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:585542825c4bc662221fb257889e011a5aa00f1ae4d75d1d246a5225289183e3", size = 1700586 }, - { url = "https://files.pythonhosted.org/packages/da/04/73f5f02ff348a3558763ff6abe99c223381b0bace05cd4530a0258e52597/aiohttp-3.13.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39d02cb6025fe1aabca329c5632f48c9532a3dabccd859e7e2f110668972331f", size = 1768625 }, - { url = "https://files.pythonhosted.org/packages/f8/49/a825b79ffec124317265ca7d2344a86bcffeb960743487cb11988ffb3494/aiohttp-3.13.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e67446b19e014d37342f7195f592a2a948141d15a312fe0e700c2fd2f03124f6", size = 1867281 }, - { url = "https://files.pythonhosted.org/packages/b9/48/adf56e05f81eac31edcfae45c90928f4ad50ef2e3ea72cb8376162a368f8/aiohttp-3.13.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4356474ad6333e41ccefd39eae869ba15a6c5299c9c01dfdcfdd5c107be4363e", size = 1752431 }, - { url = "https://files.pythonhosted.org/packages/30/ab/593855356eead019a74e862f21523db09c27f12fd24af72dbc3555b9bfd9/aiohttp-3.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeacf451c99b4525f700f078becff32c32ec327b10dcf31306a8a52d78166de7", size = 1562846 }, - { url = "https://files.pythonhosted.org/packages/39/0f/9f3d32271aa8dc35036e9668e31870a9d3b9542dd6b3e2c8a30931cb27ae/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8a9b889aeabd7a4e9af0b7f4ab5ad94d42e7ff679aaec6d0db21e3b639ad58d", size = 1699606 }, - { url = "https://files.pythonhosted.org/packages/2c/3c/52d2658c5699b6ef7692a3f7128b2d2d4d9775f2a68093f74bca06cf01e1/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fa89cb11bc71a63b69568d5b8a25c3ca25b6d54c15f907ca1c130d72f320b76b", size = 1720663 }, - { url = "https://files.pythonhosted.org/packages/9b/d4/8f8f3ff1fb7fb9e3f04fcad4e89d8a1cd8fc7d05de67e3de5b15b33008ff/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8aa7c807df234f693fed0ecd507192fc97692e61fee5702cdc11155d2e5cadc8", size = 1737939 }, - { url = "https://files.pythonhosted.org/packages/03/d3/ddd348f8a27a634daae39a1b8e291ff19c77867af438af844bf8b7e3231b/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9eb3e33fdbe43f88c3c75fa608c25e7c47bbd80f48d012763cb67c47f39a7e16", size = 1555132 }, - { url = "https://files.pythonhosted.org/packages/39/b8/46790692dc46218406f94374903ba47552f2f9f90dad554eed61bfb7b64c/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9434bc0d80076138ea986833156c5a48c9c7a8abb0c96039ddbb4afc93184169", size = 1764802 }, - { url = "https://files.pythonhosted.org/packages/ba/e4/19ce547b58ab2a385e5f0b8aa3db38674785085abcf79b6e0edd1632b12f/aiohttp-3.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff15c147b2ad66da1f2cbb0622313f2242d8e6e8f9b79b5206c84523a4473248", size = 1719512 }, - { url = "https://files.pythonhosted.org/packages/70/30/6355a737fed29dcb6dfdd48682d5790cb5eab050f7b4e01f49b121d3acad/aiohttp-3.13.2-cp312-cp312-win32.whl", hash = "sha256:27e569eb9d9e95dbd55c0fc3ec3a9335defbf1d8bc1d20171a49f3c4c607b93e", size = 426690 }, - { url = "https://files.pythonhosted.org/packages/0a/0d/b10ac09069973d112de6ef980c1f6bb31cb7dcd0bc363acbdad58f927873/aiohttp-3.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:8709a0f05d59a71f33fd05c17fc11fcb8c30140506e13c2f5e8ee1b8964e1b45", size = 453465 }, - { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139 }, - { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082 }, - { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035 }, - { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387 }, - { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314 }, - { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317 }, - { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539 }, - { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597 }, - { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006 }, - { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220 }, - { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570 }, - { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407 }, - { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093 }, - { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084 }, - { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987 }, - { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859 }, - { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192 }, - { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234 }, - { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733 }, - { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303 }, - { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965 }, - { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221 }, - { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178 }, - { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001 }, - { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325 }, - { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978 }, - { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042 }, - { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085 }, - { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238 }, - { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395 }, - { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965 }, - { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585 }, - { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621 }, - { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627 }, - { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360 }, - { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616 }, - { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131 }, - { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168 }, - { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200 }, - { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497 }, - { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703 }, - { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738 }, - { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061 }, - { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201 }, - { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868 }, - { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660 }, - { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548 }, - { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240 }, - { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334 }, - { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685 }, - { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093 }, - { url = "https://files.pythonhosted.org/packages/04/4a/3da532fdf51b5e58fffa1a86d6569184cb1bf4bf81cd4434b6541a8d14fd/aiohttp-3.13.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7fbdf5ad6084f1940ce88933de34b62358d0f4a0b6ec097362dcd3e5a65a4989", size = 739009 }, - { url = "https://files.pythonhosted.org/packages/89/74/fefa6f7939cdc1d77e5cad712004e675a8847dccc589dcc3abca7feaed73/aiohttp-3.13.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7c3a50345635a02db61792c85bb86daffac05330f6473d524f1a4e3ef9d0046d", size = 495308 }, - { url = "https://files.pythonhosted.org/packages/4e/b4/a0638ae1f12d09a0dc558870968a2f19a1eba1b10ad0a85ef142ddb40b50/aiohttp-3.13.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e87dff73f46e969af38ab3f7cb75316a7c944e2e574ff7c933bc01b10def7f5", size = 490624 }, - { url = "https://files.pythonhosted.org/packages/02/73/361cd4cac9d98a5a4183d1f26faf7b777330f8dba838c5aae2412862bdd0/aiohttp-3.13.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2adebd4577724dcae085665f294cc57c8701ddd4d26140504db622b8d566d7aa", size = 1662968 }, - { url = "https://files.pythonhosted.org/packages/9e/93/ce2ca7584555a6c7dd78f2e6b539a96c5172d88815e13a05a576e14a5a22/aiohttp-3.13.2-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e036a3a645fe92309ec34b918394bb377950cbb43039a97edae6c08db64b23e2", size = 1627117 }, - { url = "https://files.pythonhosted.org/packages/a6/42/7ee0e699111f5fc20a69b3203e8f5d5da0b681f270b90bc088d15e339980/aiohttp-3.13.2-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:23ad365e30108c422d0b4428cf271156dd56790f6dd50d770b8e360e6c5ab2e6", size = 1724037 }, - { url = "https://files.pythonhosted.org/packages/66/88/67ad5ff11dd61dd1d7882cda39f085d5fca31cf7e2143f5173429d8a591e/aiohttp-3.13.2-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f9b2c2d4b9d958b1f9ae0c984ec1dd6b6689e15c75045be8ccb4011426268ca", size = 1812899 }, - { url = "https://files.pythonhosted.org/packages/60/1b/a46f6e1c2a347b9c7a789292279c159b327fadecbf8340f3b05fffff1151/aiohttp-3.13.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a92cf4b9bea33e15ecbaa5c59921be0f23222608143d025c989924f7e3e0c07", size = 1660961 }, - { url = "https://files.pythonhosted.org/packages/44/cc/1af9e466eafd9b5d8922238c69aaf95b656137add4c5db65f63ee129bf3c/aiohttp-3.13.2-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:070599407f4954021509193404c4ac53153525a19531051661440644728ba9a7", size = 1553851 }, - { url = "https://files.pythonhosted.org/packages/e5/d1/9e5f4f40f9d0ee5668e9b5e7ebfb0eaf371cc09da03785decdc5da56f4b3/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:29562998ec66f988d49fb83c9b01694fa927186b781463f376c5845c121e4e0b", size = 1634260 }, - { url = "https://files.pythonhosted.org/packages/83/2e/5d065091c4ae8b55a153f458f19308191bad3b62a89496aa081385486338/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4dd3db9d0f4ebca1d887d76f7cdbcd1116ac0d05a9221b9dad82c64a62578c4d", size = 1639499 }, - { url = "https://files.pythonhosted.org/packages/a3/de/58ae6dc73691a51ff16f69a94d13657bf417456fa0fdfed2b59dd6b4c293/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:d7bc4b7f9c4921eba72677cd9fedd2308f4a4ca3e12fab58935295ad9ea98700", size = 1694087 }, - { url = "https://files.pythonhosted.org/packages/45/fe/4d9df516268867d83041b6c073ee15cd532dbea58b82d675a7e1cf2ec24c/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:dacd50501cd017f8cccb328da0c90823511d70d24a323196826d923aad865901", size = 1540532 }, - { url = "https://files.pythonhosted.org/packages/24/e7/a802619308232499482bf30b3530efb5d141481cfd61850368350fb1acb5/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:8b2f1414f6a1e0683f212ec80e813f4abef94c739fd090b66c9adf9d2a05feac", size = 1710369 }, - { url = "https://files.pythonhosted.org/packages/62/08/e8593f39f025efe96ef59550d17cf097222d84f6f84798bedac5bf037fce/aiohttp-3.13.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:04c3971421576ed24c191f610052bcb2f059e395bc2489dd99e397f9bc466329", size = 1649296 }, - { url = "https://files.pythonhosted.org/packages/e5/fd/ffbc1b6aa46fc6c284af4a438b2c7eab79af1c8ac4b6d2ced185c17f403e/aiohttp-3.13.2-cp39-cp39-win32.whl", hash = "sha256:9f377d0a924e5cc94dc620bc6366fc3e889586a7f18b748901cf016c916e2084", size = 432980 }, - { url = "https://files.pythonhosted.org/packages/ad/a9/d47e7873175a4d8aed425f2cdea2df700b2dd44fac024ffbd83455a69a50/aiohttp-3.13.2-cp39-cp39-win_amd64.whl", hash = "sha256:9c705601e16c03466cb72011bd1af55d68fa65b045356d8f96c216e5f6db0fa5", size = 456021 }, -] - -[[package]] -name = "aiohttp-cors" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "aiohttp", version = "3.10.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/44/9e/6cdce7c3f346d8fd487adf68761728ad8cd5fbc296a7b07b92518350d31f/aiohttp-cors-0.7.0.tar.gz", hash = "sha256:4d39c6d7100fd9764ed1caf8cebf0eb01bf5e3f24e2e073fda6234bc48b19f5d", size = 35966 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/e7/e436a0c0eb5127d8b491a9b83ecd2391c6ff7dcd5548dfaec2080a2340fd/aiohttp_cors-0.7.0-py3-none-any.whl", hash = "sha256:0451ba59fdf6909d0e2cd21e4c0a43752bc0703d33fc78ae94d9d9321710193e", size = 27564 }, -] - -[[package]] -name = "aiohttp-cors" -version = "0.8.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "aiohttp", version = "3.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/d89e846a5444b3d5eb8985a6ddb0daef3774928e1bfbce8e84ec97b0ffa7/aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403", size = 38626 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/3b/40a68de458904bcc143622015fff2352b6461cd92fd66d3527bf1c6f5716/aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d", size = 25231 }, -] - -[[package]] -name = "aiosignal" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "frozenlist", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ae/67/0952ed97a9793b4958e5736f6d2b346b414a2cd63e82d05940032f45b32f/aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", size = 19422 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17", size = 7617 }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "frozenlist", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490 }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, -] - -[[package]] -name = "anyio" -version = "4.5.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.9'" }, - { name = "idna", marker = "python_full_version < '3.9'" }, - { name = "sniffio", marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4d/f9/9a7ce600ebe7804daf90d4d48b1c0510a4561ddce43a596be46676f82343/anyio-4.5.2.tar.gz", hash = "sha256:23009af4ed04ce05991845451e11ef02fc7c5ed29179ac9a420e5ad0ac7ddc5b", size = 171293 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/b4/f7e396030e3b11394436358ca258a81d6010106582422f23443c16ca1873/anyio-4.5.2-py3-none-any.whl", hash = "sha256:c011ee36bc1e8ba40e5a81cb9df91925c218fe9b778554e0b56a21e1b5d4716f", size = 89766 }, -] - -[[package]] -name = "anyio" -version = "4.12.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "exceptiongroup", marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, - { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362 }, -] - -[[package]] -name = "async-timeout" -version = "5.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233 }, -] - -[[package]] -name = "attrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 }, -] - -[[package]] -name = "attrs" -version = "25.4.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615 }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438 }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709 }, - { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814 }, - { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467 }, - { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280 }, - { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454 }, - { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609 }, - { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849 }, - { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586 }, - { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290 }, - { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663 }, - { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964 }, - { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064 }, - { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015 }, - { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792 }, - { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198 }, - { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262 }, - { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988 }, - { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324 }, - { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742 }, - { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863 }, - { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837 }, - { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550 }, - { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162 }, - { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019 }, - { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310 }, - { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022 }, - { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383 }, - { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098 }, - { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991 }, - { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456 }, - { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978 }, - { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969 }, - { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425 }, - { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162 }, - { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558 }, - { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497 }, - { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240 }, - { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471 }, - { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864 }, - { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647 }, - { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110 }, - { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839 }, - { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667 }, - { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535 }, - { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816 }, - { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694 }, - { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131 }, - { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390 }, - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091 }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936 }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180 }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346 }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874 }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076 }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601 }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376 }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825 }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583 }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366 }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300 }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465 }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404 }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092 }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408 }, - { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746 }, - { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889 }, - { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641 }, - { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779 }, - { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035 }, - { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542 }, - { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524 }, - { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395 }, - { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680 }, - { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045 }, - { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687 }, - { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014 }, - { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044 }, - { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940 }, - { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104 }, - { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743 }, - { url = "https://files.pythonhosted.org/packages/0a/4e/3926a1c11f0433791985727965263f788af00db3482d89a7545ca5ecc921/charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", size = 198599 }, - { url = "https://files.pythonhosted.org/packages/ec/7c/b92d1d1dcffc34592e71ea19c882b6709e43d20fa498042dea8b815638d7/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", size = 143090 }, - { url = "https://files.pythonhosted.org/packages/84/ce/61a28d3bb77281eb24107b937a497f3c43089326d27832a63dcedaab0478/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", size = 139490 }, - { url = "https://files.pythonhosted.org/packages/c0/bd/c9e59a91b2061c6f8bb98a150670cb16d4cd7c4ba7d11ad0cdf789155f41/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af", size = 155334 }, - { url = "https://files.pythonhosted.org/packages/bf/37/f17ae176a80f22ff823456af91ba3bc59df308154ff53aef0d39eb3d3419/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2", size = 152823 }, - { url = "https://files.pythonhosted.org/packages/bf/fa/cf5bb2409a385f78750e78c8d2e24780964976acdaaed65dbd6083ae5b40/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d", size = 147618 }, - { url = "https://files.pythonhosted.org/packages/9b/63/579784a65bc7de2d4518d40bb8f1870900163e86f17f21fd1384318c459d/charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3", size = 145516 }, - { url = "https://files.pythonhosted.org/packages/a3/a9/94ec6266cd394e8f93a4d69cca651d61bf6ac58d2a0422163b30c698f2c7/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63", size = 145266 }, - { url = "https://files.pythonhosted.org/packages/09/14/d6626eb97764b58c2779fa7928fa7d1a49adb8ce687c2dbba4db003c1939/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7", size = 139559 }, - { url = "https://files.pythonhosted.org/packages/09/01/ddbe6b01313ba191dbb0a43c7563bc770f2448c18127f9ea4b119c44dff0/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4", size = 156653 }, - { url = "https://files.pythonhosted.org/packages/95/c8/d05543378bea89296e9af4510b44c704626e191da447235c8fdedfc5b7b2/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf", size = 145644 }, - { url = "https://files.pythonhosted.org/packages/72/01/2866c4377998ef8a1f6802f6431e774a4c8ebe75b0a6e569ceec55c9cbfb/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074", size = 153964 }, - { url = "https://files.pythonhosted.org/packages/4a/66/66c72468a737b4cbd7851ba2c522fe35c600575fbeac944460b4fd4a06fe/charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a", size = 148777 }, - { url = "https://files.pythonhosted.org/packages/50/94/d0d56677fdddbffa8ca00ec411f67bb8c947f9876374ddc9d160d4f2c4b3/charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa", size = 98687 }, - { url = "https://files.pythonhosted.org/packages/00/64/c3bc303d1b586480b1c8e6e1e2191a6d6dd40255244e5cf16763dcec52e6/charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576", size = 106115 }, - { url = "https://files.pythonhosted.org/packages/46/7c/0c4760bccf082737ca7ab84a4c2034fcc06b1f21cf3032ea98bd6feb1725/charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9", size = 209609 }, - { url = "https://files.pythonhosted.org/packages/bb/a4/69719daef2f3d7f1819de60c9a6be981b8eeead7542d5ec4440f3c80e111/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d", size = 149029 }, - { url = "https://files.pythonhosted.org/packages/e6/21/8d4e1d6c1e6070d3672908b8e4533a71b5b53e71d16828cc24d0efec564c/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608", size = 144580 }, - { url = "https://files.pythonhosted.org/packages/a7/0a/a616d001b3f25647a9068e0b9199f697ce507ec898cacb06a0d5a1617c99/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc", size = 162340 }, - { url = "https://files.pythonhosted.org/packages/85/93/060b52deb249a5450460e0585c88a904a83aec474ab8e7aba787f45e79f2/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e", size = 159619 }, - { url = "https://files.pythonhosted.org/packages/dd/21/0274deb1cc0632cd587a9a0ec6b4674d9108e461cb4cd40d457adaeb0564/charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1", size = 153980 }, - { url = "https://files.pythonhosted.org/packages/28/2b/e3d7d982858dccc11b31906976323d790dded2017a0572f093ff982d692f/charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3", size = 152174 }, - { url = "https://files.pythonhosted.org/packages/6e/ff/4a269f8e35f1e58b2df52c131a1fa019acb7ef3f8697b7d464b07e9b492d/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6", size = 151666 }, - { url = "https://files.pythonhosted.org/packages/da/c9/ec39870f0b330d58486001dd8e532c6b9a905f5765f58a6f8204926b4a93/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88", size = 145550 }, - { url = "https://files.pythonhosted.org/packages/75/8f/d186ab99e40e0ed9f82f033d6e49001701c81244d01905dd4a6924191a30/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1", size = 163721 }, - { url = "https://files.pythonhosted.org/packages/96/b1/6047663b9744df26a7e479ac1e77af7134b1fcf9026243bb48ee2d18810f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf", size = 152127 }, - { url = "https://files.pythonhosted.org/packages/59/78/e5a6eac9179f24f704d1be67d08704c3c6ab9f00963963524be27c18ed87/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318", size = 161175 }, - { url = "https://files.pythonhosted.org/packages/e5/43/0e626e42d54dd2f8dd6fc5e1c5ff00f05fbca17cb699bedead2cae69c62f/charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c", size = 155375 }, - { url = "https://files.pythonhosted.org/packages/e9/91/d9615bf2e06f35e4997616ff31248c3657ed649c5ab9d35ea12fce54e380/charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505", size = 99692 }, - { url = "https://files.pythonhosted.org/packages/d1/a9/6c040053909d9d1ef4fcab45fddec083aedc9052c10078339b47c8573ea8/charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966", size = 107192 }, - { url = "https://files.pythonhosted.org/packages/f0/c6/4fa536b2c0cd3edfb7ccf8469fa0f363ea67b7213a842b90909ca33dd851/charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50", size = 100220 }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402 }, -] - -[[package]] -name = "click" -version = "8.1.8" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, -] - -[[package]] -name = "click" -version = "8.3.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274 }, -] - -[[package]] -name = "click-default-group" -version = "1.2.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/1d/ce/edb087fb53de63dad3b36408ca30368f438738098e668b78c87f93cd41df/click_default_group-1.2.4.tar.gz", hash = "sha256:eb3f3c99ec0d456ca6cd2a7f08f7d4e91771bef51b01bdd9580cc6450fe1251e", size = 3505 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/1a/aff8bb287a4b1400f69e09a53bd65de96aa5cee5691925b38731c67fc695/click_default_group-1.2.4-py2.py3-none-any.whl", hash = "sha256:9b60486923720e7fc61731bdb32b617039aba820e22e1c88766b1125592eaa5f", size = 4123 }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, -] - -[[package]] -name = "condense-json" -version = "0.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/b3/d784cbc05556192ea1e798cae96363835d649fe7420ff030190789645be1/condense_json-0.1.3.tar.gz", hash = "sha256:25fe8d434fdafd849e8d98f21a3e18f96ae2d6dbc2c17565f29e4843d039d2bc", size = 8697 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/5f/63badd4924358fad1efa6defd66eef700ccf8783c0e44098987f867e8b1f/condense_json-0.1.3-py3-none-any.whl", hash = "sha256:e0a3d42db4f44a89e74af8737d8e517e97420be0f7e5437087f4decfd38c3366", size = 8432 }, -] - -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, -] - -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, -] - -[[package]] -name = "frozenlist" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/79/29d44c4af36b2b240725dce566b20f63f9b36ef267aaaa64ee7466f4f2f8/frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a", size = 94451 }, - { url = "https://files.pythonhosted.org/packages/47/47/0c999aeace6ead8a44441b4f4173e2261b18219e4ad1fe9a479871ca02fc/frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb", size = 54301 }, - { url = "https://files.pythonhosted.org/packages/8d/60/107a38c1e54176d12e06e9d4b5d755b677d71d1219217cee063911b1384f/frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec", size = 52213 }, - { url = "https://files.pythonhosted.org/packages/17/62/594a6829ac5679c25755362a9dc93486a8a45241394564309641425d3ff6/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5", size = 240946 }, - { url = "https://files.pythonhosted.org/packages/7e/75/6c8419d8f92c80dd0ee3f63bdde2702ce6398b0ac8410ff459f9b6f2f9cb/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76", size = 264608 }, - { url = "https://files.pythonhosted.org/packages/88/3e/82a6f0b84bc6fb7e0be240e52863c6d4ab6098cd62e4f5b972cd31e002e8/frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17", size = 261361 }, - { url = "https://files.pythonhosted.org/packages/fd/85/14e5f9ccac1b64ff2f10c927b3ffdf88772aea875882406f9ba0cec8ad84/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba", size = 231649 }, - { url = "https://files.pythonhosted.org/packages/ee/59/928322800306f6529d1852323014ee9008551e9bb027cc38d276cbc0b0e7/frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d", size = 241853 }, - { url = "https://files.pythonhosted.org/packages/7d/bd/e01fa4f146a6f6c18c5d34cab8abdc4013774a26c4ff851128cd1bd3008e/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2", size = 243652 }, - { url = "https://files.pythonhosted.org/packages/a5/bd/e4771fd18a8ec6757033f0fa903e447aecc3fbba54e3630397b61596acf0/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f", size = 241734 }, - { url = "https://files.pythonhosted.org/packages/21/13/c83821fa5544af4f60c5d3a65d054af3213c26b14d3f5f48e43e5fb48556/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c", size = 260959 }, - { url = "https://files.pythonhosted.org/packages/71/f3/1f91c9a9bf7ed0e8edcf52698d23f3c211d8d00291a53c9f115ceb977ab1/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab", size = 262706 }, - { url = "https://files.pythonhosted.org/packages/4c/22/4a256fdf5d9bcb3ae32622c796ee5ff9451b3a13a68cfe3f68e2c95588ce/frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5", size = 250401 }, - { url = "https://files.pythonhosted.org/packages/af/89/c48ebe1f7991bd2be6d5f4ed202d94960c01b3017a03d6954dd5fa9ea1e8/frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb", size = 45498 }, - { url = "https://files.pythonhosted.org/packages/28/2f/cc27d5f43e023d21fe5c19538e08894db3d7e081cbf582ad5ed366c24446/frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4", size = 51622 }, - { url = "https://files.pythonhosted.org/packages/79/43/0bed28bf5eb1c9e4301003b74453b8e7aa85fb293b31dde352aac528dafc/frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30", size = 94987 }, - { url = "https://files.pythonhosted.org/packages/bb/bf/b74e38f09a246e8abbe1e90eb65787ed745ccab6eaa58b9c9308e052323d/frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5", size = 54584 }, - { url = "https://files.pythonhosted.org/packages/2c/31/ab01375682f14f7613a1ade30149f684c84f9b8823a4391ed950c8285656/frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778", size = 52499 }, - { url = "https://files.pythonhosted.org/packages/98/a8/d0ac0b9276e1404f58fec3ab6e90a4f76b778a49373ccaf6a563f100dfbc/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a", size = 276357 }, - { url = "https://files.pythonhosted.org/packages/ad/c9/c7761084fa822f07dac38ac29f841d4587570dd211e2262544aa0b791d21/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869", size = 287516 }, - { url = "https://files.pythonhosted.org/packages/a1/ff/cd7479e703c39df7bdab431798cef89dc75010d8aa0ca2514c5b9321db27/frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d", size = 283131 }, - { url = "https://files.pythonhosted.org/packages/59/a0/370941beb47d237eca4fbf27e4e91389fd68699e6f4b0ebcc95da463835b/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45", size = 261320 }, - { url = "https://files.pythonhosted.org/packages/b8/5f/c10123e8d64867bc9b4f2f510a32042a306ff5fcd7e2e09e5ae5100ee333/frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d", size = 274877 }, - { url = "https://files.pythonhosted.org/packages/fa/79/38c505601ae29d4348f21706c5d89755ceded02a745016ba2f58bd5f1ea6/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3", size = 269592 }, - { url = "https://files.pythonhosted.org/packages/19/e2/39f3a53191b8204ba9f0bb574b926b73dd2efba2a2b9d2d730517e8f7622/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a", size = 265934 }, - { url = "https://files.pythonhosted.org/packages/d5/c9/3075eb7f7f3a91f1a6b00284af4de0a65a9ae47084930916f5528144c9dd/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9", size = 283859 }, - { url = "https://files.pythonhosted.org/packages/05/f5/549f44d314c29408b962fa2b0e69a1a67c59379fb143b92a0a065ffd1f0f/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2", size = 287560 }, - { url = "https://files.pythonhosted.org/packages/9d/f8/cb09b3c24a3eac02c4c07a9558e11e9e244fb02bf62c85ac2106d1eb0c0b/frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf", size = 277150 }, - { url = "https://files.pythonhosted.org/packages/37/48/38c2db3f54d1501e692d6fe058f45b6ad1b358d82cd19436efab80cfc965/frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942", size = 45244 }, - { url = "https://files.pythonhosted.org/packages/ca/8c/2ddffeb8b60a4bce3b196c32fcc30d8830d4615e7b492ec2071da801b8ad/frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d", size = 51634 }, - { url = "https://files.pythonhosted.org/packages/79/73/fa6d1a96ab7fd6e6d1c3500700963eab46813847f01ef0ccbaa726181dd5/frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21", size = 94026 }, - { url = "https://files.pythonhosted.org/packages/ab/04/ea8bf62c8868b8eada363f20ff1b647cf2e93377a7b284d36062d21d81d1/frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d", size = 54150 }, - { url = "https://files.pythonhosted.org/packages/d0/9a/8e479b482a6f2070b26bda572c5e6889bb3ba48977e81beea35b5ae13ece/frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e", size = 51927 }, - { url = "https://files.pythonhosted.org/packages/e3/12/2aad87deb08a4e7ccfb33600871bbe8f0e08cb6d8224371387f3303654d7/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a", size = 282647 }, - { url = "https://files.pythonhosted.org/packages/77/f2/07f06b05d8a427ea0060a9cef6e63405ea9e0d761846b95ef3fb3be57111/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a", size = 289052 }, - { url = "https://files.pythonhosted.org/packages/bd/9f/8bf45a2f1cd4aa401acd271b077989c9267ae8463e7c8b1eb0d3f561b65e/frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee", size = 291719 }, - { url = "https://files.pythonhosted.org/packages/41/d1/1f20fd05a6c42d3868709b7604c9f15538a29e4f734c694c6bcfc3d3b935/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6", size = 267433 }, - { url = "https://files.pythonhosted.org/packages/af/f2/64b73a9bb86f5a89fb55450e97cd5c1f84a862d4ff90d9fd1a73ab0f64a5/frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e", size = 283591 }, - { url = "https://files.pythonhosted.org/packages/29/e2/ffbb1fae55a791fd6c2938dd9ea779509c977435ba3940b9f2e8dc9d5316/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9", size = 273249 }, - { url = "https://files.pythonhosted.org/packages/2e/6e/008136a30798bb63618a114b9321b5971172a5abddff44a100c7edc5ad4f/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039", size = 271075 }, - { url = "https://files.pythonhosted.org/packages/ae/f0/4e71e54a026b06724cec9b6c54f0b13a4e9e298cc8db0f82ec70e151f5ce/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784", size = 285398 }, - { url = "https://files.pythonhosted.org/packages/4d/36/70ec246851478b1c0b59f11ef8ade9c482ff447c1363c2bd5fad45098b12/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631", size = 294445 }, - { url = "https://files.pythonhosted.org/packages/37/e0/47f87544055b3349b633a03c4d94b405956cf2437f4ab46d0928b74b7526/frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f", size = 280569 }, - { url = "https://files.pythonhosted.org/packages/f9/7c/490133c160fb6b84ed374c266f42800e33b50c3bbab1652764e6e1fc498a/frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8", size = 44721 }, - { url = "https://files.pythonhosted.org/packages/b1/56/4e45136ffc6bdbfa68c29ca56ef53783ef4c2fd395f7cbf99a2624aa9aaa/frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f", size = 51329 }, - { url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 }, - { url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 }, - { url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 }, - { url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 }, - { url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 }, - { url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 }, - { url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 }, - { url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 }, - { url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 }, - { url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 }, - { url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 }, - { url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 }, - { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 }, - { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 }, - { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 }, - { url = "https://files.pythonhosted.org/packages/33/b5/00fcbe8e7e7e172829bf4addc8227d8f599a3d5def3a4e9aa2b54b3145aa/frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca", size = 95648 }, - { url = "https://files.pythonhosted.org/packages/1e/69/e4a32fc4b2fa8e9cb6bcb1bad9c7eeb4b254bc34da475b23f93264fdc306/frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10", size = 54888 }, - { url = "https://files.pythonhosted.org/packages/76/a3/c08322a91e73d1199901a77ce73971cffa06d3c74974270ff97aed6e152a/frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604", size = 52975 }, - { url = "https://files.pythonhosted.org/packages/fc/60/a315321d8ada167b578ff9d2edc147274ead6129523b3a308501b6621b4f/frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3", size = 241912 }, - { url = "https://files.pythonhosted.org/packages/bd/d0/1f0980987bca4f94f9e8bae01980b23495ffc2e5049a3da4d9b7d2762bee/frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307", size = 259433 }, - { url = "https://files.pythonhosted.org/packages/28/e7/d00600c072eec8f18a606e281afdf0e8606e71a4882104d0438429b02468/frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10", size = 255576 }, - { url = "https://files.pythonhosted.org/packages/82/71/993c5f45dba7be347384ddec1ebc1b4d998291884e7690c06aa6ba755211/frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9", size = 233349 }, - { url = "https://files.pythonhosted.org/packages/66/30/f9c006223feb2ac87f1826b57f2367b60aacc43092f562dab60d2312562e/frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99", size = 243126 }, - { url = "https://files.pythonhosted.org/packages/b5/34/e4219c9343f94b81068d0018cbe37948e66c68003b52bf8a05e9509d09ec/frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c", size = 241261 }, - { url = "https://files.pythonhosted.org/packages/48/96/9141758f6a19f2061a51bb59b9907c92f9bda1ac7b2baaf67a6e352b280f/frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171", size = 240203 }, - { url = "https://files.pythonhosted.org/packages/f9/71/0ef5970e68d181571a050958e84c76a061ca52f9c6f50257d9bfdd84c7f7/frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e", size = 267539 }, - { url = "https://files.pythonhosted.org/packages/ab/bd/6e7d450c5d993b413591ad9cdab6dcdfa2c6ab2cd835b2b5c1cfeb0323bf/frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf", size = 268518 }, - { url = "https://files.pythonhosted.org/packages/cc/3d/5a7c4dfff1ae57ca2cbbe9041521472ecd9446d49e7044a0e9bfd0200fd0/frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e", size = 248114 }, - { url = "https://files.pythonhosted.org/packages/f7/41/2342ec4c714349793f1a1e7bd5c4aeec261e24e697fa9a5499350c3a2415/frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723", size = 45648 }, - { url = "https://files.pythonhosted.org/packages/0c/90/85bb3547c327f5975078c1be018478d5e8d250a540c828f8f31a35d2a1bd/frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923", size = 51930 }, - { url = "https://files.pythonhosted.org/packages/da/4d/d94ff0fb0f5313902c132817c62d19cdc5bdcd0c195d392006ef4b779fc6/frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972", size = 95319 }, - { url = "https://files.pythonhosted.org/packages/8c/1b/d90e554ca2b483d31cb2296e393f72c25bdc38d64526579e95576bfda587/frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336", size = 54749 }, - { url = "https://files.pythonhosted.org/packages/f8/66/7fdecc9ef49f8db2aa4d9da916e4ecf357d867d87aea292efc11e1b2e932/frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f", size = 52718 }, - { url = "https://files.pythonhosted.org/packages/08/04/e2fddc92135276e07addbc1cf413acffa0c2d848b3e54cacf684e146df49/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f", size = 241756 }, - { url = "https://files.pythonhosted.org/packages/c6/52/be5ff200815d8a341aee5b16b6b707355e0ca3652953852238eb92b120c2/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6", size = 267718 }, - { url = "https://files.pythonhosted.org/packages/88/be/4bd93a58be57a3722fc544c36debdf9dcc6758f761092e894d78f18b8f20/frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411", size = 263494 }, - { url = "https://files.pythonhosted.org/packages/32/ba/58348b90193caa096ce9e9befea6ae67f38dabfd3aacb47e46137a6250a8/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08", size = 232838 }, - { url = "https://files.pythonhosted.org/packages/f6/33/9f152105227630246135188901373c4f322cc026565ca6215b063f4c82f4/frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2", size = 242912 }, - { url = "https://files.pythonhosted.org/packages/a0/10/3db38fb3ccbafadd80a1b0d6800c987b0e3fe3ef2d117c6ced0246eea17a/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d", size = 244763 }, - { url = "https://files.pythonhosted.org/packages/e2/cd/1df468fdce2f66a4608dffe44c40cdc35eeaa67ef7fd1d813f99a9a37842/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b", size = 242841 }, - { url = "https://files.pythonhosted.org/packages/ee/5f/16097a5ca0bb6b6779c02cc9379c72fe98d56115d4c54d059fb233168fb6/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b", size = 263407 }, - { url = "https://files.pythonhosted.org/packages/0f/f7/58cd220ee1c2248ee65a32f5b4b93689e3fe1764d85537eee9fc392543bc/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0", size = 265083 }, - { url = "https://files.pythonhosted.org/packages/62/b8/49768980caabf81ac4a2d156008f7cbd0107e6b36d08a313bb31035d9201/frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c", size = 251564 }, - { url = "https://files.pythonhosted.org/packages/cb/83/619327da3b86ef957ee7a0cbf3c166a09ed1e87a3f7f1ff487d7d0284683/frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3", size = 45691 }, - { url = "https://files.pythonhosted.org/packages/8b/28/407bc34a745151ed2322c690b6e7d83d7101472e81ed76e1ebdac0b70a78/frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0", size = 51767 }, - { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230 }, - { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621 }, - { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889 }, - { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464 }, - { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649 }, - { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188 }, - { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748 }, - { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351 }, - { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767 }, - { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887 }, - { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785 }, - { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312 }, - { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650 }, - { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659 }, - { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837 }, - { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989 }, - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912 }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046 }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119 }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067 }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160 }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544 }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797 }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923 }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886 }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731 }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544 }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806 }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382 }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647 }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064 }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937 }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782 }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594 }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448 }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411 }, - { url = "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", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014 }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909 }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049 }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485 }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619 }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320 }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820 }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518 }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096 }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985 }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591 }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102 }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717 }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651 }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417 }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391 }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048 }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549 }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833 }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363 }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314 }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365 }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763 }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110 }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717 }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628 }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882 }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676 }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235 }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742 }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725 }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506 }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161 }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676 }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638 }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067 }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101 }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901 }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395 }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659 }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492 }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034 }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749 }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127 }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698 }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749 }, - { url = "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", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298 }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015 }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038 }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130 }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845 }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131 }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542 }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308 }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210 }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972 }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536 }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330 }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627 }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238 }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738 }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739 }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186 }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196 }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830 }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289 }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318 }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814 }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762 }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470 }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042 }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148 }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676 }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451 }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507 }, - { url = "https://files.pythonhosted.org/packages/c2/59/ae5cdac87a00962122ea37bb346d41b66aec05f9ce328fa2b9e216f8967b/frozenlist-1.8.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d8b7138e5cd0647e4523d6685b0eac5d4be9a184ae9634492f25c6eb38c12a47", size = 86967 }, - { url = "https://files.pythonhosted.org/packages/8a/10/17059b2db5a032fd9323c41c39e9d1f5f9d0c8f04d1e4e3e788573086e61/frozenlist-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a6483e309ca809f1efd154b4d37dc6d9f61037d6c6a81c2dc7a15cb22c8c5dca", size = 49984 }, - { url = "https://files.pythonhosted.org/packages/4b/de/ad9d82ca8e5fa8f0c636e64606553c79e2b859ad253030b62a21fe9986f5/frozenlist-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b9290cf81e95e93fdf90548ce9d3c1211cf574b8e3f4b3b7cb0537cf2227068", size = 50240 }, - { url = "https://files.pythonhosted.org/packages/4e/45/3dfb7767c2a67d123650122b62ce13c731b6c745bc14424eea67678b508c/frozenlist-1.8.0-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59a6a5876ca59d1b63af8cd5e7ffffb024c3dc1e9cf9301b21a2e76286505c95", size = 219472 }, - { url = "https://files.pythonhosted.org/packages/0b/bf/5bf23d913a741b960d5c1dac7c1985d8a2a1d015772b2d18ea168b08e7ff/frozenlist-1.8.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6dc4126390929823e2d2d9dc79ab4046ed74680360fc5f38b585c12c66cdf459", size = 221531 }, - { url = "https://files.pythonhosted.org/packages/d0/03/27ec393f3b55860859f4b74cdc8c2a4af3dbf3533305e8eacf48a4fd9a54/frozenlist-1.8.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:332db6b2563333c5671fecacd085141b5800cb866be16d5e3eb15a2086476675", size = 219211 }, - { url = "https://files.pythonhosted.org/packages/3a/ad/0fd00c404fa73fe9b169429e9a972d5ed807973c40ab6b3cf9365a33d360/frozenlist-1.8.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ff15928d62a0b80bb875655c39bf517938c7d589554cbd2669be42d97c2cb61", size = 231775 }, - { url = "https://files.pythonhosted.org/packages/8a/c3/86962566154cb4d2995358bc8331bfc4ea19d07db1a96f64935a1607f2b6/frozenlist-1.8.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7bf6cdf8e07c8151fba6fe85735441240ec7f619f935a5205953d58009aef8c6", size = 236631 }, - { url = "https://files.pythonhosted.org/packages/ea/9e/6ffad161dbd83782d2c66dc4d378a9103b31770cb1e67febf43aea42d202/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:48e6d3f4ec5c7273dfe83ff27c91083c6c9065af655dc2684d2c200c94308bb5", size = 218632 }, - { url = "https://files.pythonhosted.org/packages/58/b2/4677eee46e0a97f9b30735e6ad0bf6aba3e497986066eb68807ac85cf60f/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:1a7607e17ad33361677adcd1443edf6f5da0ce5e5377b798fba20fae194825f3", size = 235967 }, - { url = "https://files.pythonhosted.org/packages/05/f3/86e75f8639c5a93745ca7addbbc9de6af56aebb930d233512b17e46f6493/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3a935c3a4e89c733303a2d5a7c257ea44af3a56c8202df486b7f5de40f37e1", size = 228799 }, - { url = "https://files.pythonhosted.org/packages/30/00/39aad3a7f0d98f5eb1d99a3c311215674ed87061aecee7851974b335c050/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:940d4a017dbfed9daf46a3b086e1d2167e7012ee297fef9e1c545c4d022f5178", size = 230566 }, - { url = "https://files.pythonhosted.org/packages/0d/4d/aa144cac44568d137846ddc4d5210fb5d9719eb1d7ec6fa2728a54b5b94a/frozenlist-1.8.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b9be22a69a014bc47e78072d0ecae716f5eb56c15238acca0f43d6eb8e4a5bda", size = 217715 }, - { url = "https://files.pythonhosted.org/packages/64/4c/8f665921667509d25a0dd72540513bc86b356c95541686f6442a3283019f/frozenlist-1.8.0-cp39-cp39-win32.whl", hash = "sha256:1aa77cb5697069af47472e39612976ed05343ff2e84a3dcf15437b232cbfd087", size = 39933 }, - { url = "https://files.pythonhosted.org/packages/79/bd/bcc926f87027fad5e59926ff12d136e1082a115025d33c032d1cd69ab377/frozenlist-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:7398c222d1d405e796970320036b1b563892b65809d9e5261487bb2c7f7b5c6a", size = 44121 }, - { url = "https://files.pythonhosted.org/packages/4c/07/9c2e4eb7584af4b705237b971b89a4155a8e57599c4483a131a39256a9a0/frozenlist-1.8.0-cp39-cp39-win_arm64.whl", hash = "sha256:b4f3b365f31c6cd4af24545ca0a244a53688cad8834e32f56831c4923b50a103", size = 40312 }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409 }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "anyio", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008 }, -] - -[[package]] -name = "jiter" -version = "0.9.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/84/72/c28662416d9807bb5a38625eadedb82d4bd14fd2700c308ece7acdb8e89f/jiter-0.9.1.tar.gz", hash = "sha256:7852990068b6e06102ecdc44c1619855a2af63347bfb5e7e009928dcacf04fdd", size = 162540 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/5f/7f6aaca7943c644b4fd220650771f39dbfb74f9690efc6fb8c0d4092a399/jiter-0.9.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:c0163baa7ee85860fdc14cc39263014500df901eeffdf94c1eab9a2d713b2a9d", size = 312882 }, - { url = "https://files.pythonhosted.org/packages/86/0d/aac9eafc5d46bdf5c4f127ac1ce85e434d003bb5e3ae886f5e726a988cf6/jiter-0.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:514d4dd845e0af4da15112502e6fcb952f0721f27f17e530454e379472b90c14", size = 311743 }, - { url = "https://files.pythonhosted.org/packages/b8/54/fab1f4d8634af7bb1ad6dc49bee50ea9f649de0e5309c80192ace739f968/jiter-0.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b879faee1cc1a67fde3f3f370041239fd260ac452bd53e861aa4a94a51e3fd02", size = 1085889 }, - { url = "https://files.pythonhosted.org/packages/bd/86/bf4ed251d8035d5d72a46c8f9969bd5054fad052371cbea0cb161060e660/jiter-0.9.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:20a5ce641f93bfb8d8e336f8c4a045e491652f41eaacc707b15b245ece611e72", size = 1117896 }, - { url = "https://files.pythonhosted.org/packages/62/40/b04c40deccd5edd5f2a3853f4a80dc0ddbe157d1d523a573fb3d224315fc/jiter-0.9.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8575b1d2b49df04ca82d658882f4a432b7ed315a69126a379df4d10aeb416021", size = 1211956 }, - { url = "https://files.pythonhosted.org/packages/85/f0/114e9893e4ef5b423718efe9b3da01117539c333f06ef19543c68c8b7ed1/jiter-0.9.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc61831699904e0c58e82943f529713833db87acd13f95a3c0feb791f862d47b", size = 1219691 }, - { url = "https://files.pythonhosted.org/packages/02/9a/1aeac4541ce1c59c65dc76dbab642232da3d8db0581df3e61b8943033bd7/jiter-0.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fb733faf4d0e730d6663873249c1acb572fc8bd9dae3836ceda69751f27c5be", size = 352604 }, - { url = "https://files.pythonhosted.org/packages/6b/27/446ec6ca0a25d9d2f45ad546633a2b4a1b6a7f28fb6819c7056b163c5aee/jiter-0.9.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d903b3bb917c0df24f2ef62f587c8f32f6003cb2f97264109ca56c023262557f", size = 1147136 }, - { url = "https://files.pythonhosted.org/packages/09/9d/c8540bc097b07e106d060c21395c6fa6561223e7366c948a04ef0aa39979/jiter-0.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:eac3eb5206845b170142c016ae467eca523a25459dc9c53fcd8e154ea263406c", size = 1255843 }, - { url = "https://files.pythonhosted.org/packages/d3/61/9b377ecf4e09e325e90f77a7a4859ec933162f58ff5c6b7730aff6352033/jiter-0.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7ea0c20cfc61acc5335bb8ee36d639e6a4ded03f34f878b2b3038bb9f3bb553c", size = 1257536 }, - { url = "https://files.pythonhosted.org/packages/ed/f6/b6754e11ac9d02f05a2d713c0846ce813a69c1f6f7de7f1ae216c4e35ace/jiter-0.9.1-cp310-cp310-win32.whl", hash = "sha256:0f8f812dd6d2b4112db9ab4c1079c4fe73e553a500e936657fdda394fa2517e1", size = 214064 }, - { url = "https://files.pythonhosted.org/packages/1d/cb/7b9c5d6f73499d1fb5e97e36e8078f3bea00d7541a973117eccf9db1e079/jiter-0.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:f7f0198889170e7af6210509803e6527b402efc6c26f42e2896883597a10426f", size = 209952 }, - { url = "https://files.pythonhosted.org/packages/ee/3b/9f9deaef471e346354c832b6627e0d1b9ba3d9611d0e0fd394c2acf2a615/jiter-0.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6b8564e3198c4c8d835fc95cc54d6bcbd2fd8dc33a047fecc12c208491196995", size = 312737 }, - { url = "https://files.pythonhosted.org/packages/36/00/76fa6d519f8289aad32ec1caf3716eb700ba48e3212d1dda71e74c385a5c/jiter-0.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90b92044588d14efe89b394eca735adc4ac096eba82dc75d93c3083b1eebce8d", size = 313357 }, - { url = "https://files.pythonhosted.org/packages/b3/e9/f864ebe9ddf07761d5bdd3148b45a5d433c6cbce7c7e8be29baf806fa612/jiter-0.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3505f7f419b355c7788fcaae0dfc4c6ccbc50c0dc3633a2da797e841c5a423dc", size = 1085946 }, - { url = "https://files.pythonhosted.org/packages/82/a1/ed02d4c86d620989dcd392366daa67198961eedaf2e66f7a68f0d3846dba/jiter-0.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:93af8c3f4a3bf145c690e857a945eb5c655534bf95c67e1447d85c02e5af64d7", size = 1118090 }, - { url = "https://files.pythonhosted.org/packages/d3/01/d107531d215a57cda3cbc4adfcf3119166dd32adc1c332c1f3f36efd3484/jiter-0.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43b81dd21e260a249780764921b1f9a6379cb31e24e7b61e6bf0799f38ec4b91", size = 1212231 }, - { url = "https://files.pythonhosted.org/packages/45/1e/6801a81a2ef1f917fe9a7d2139e576dd4f53497c309dab9461136922709c/jiter-0.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:db639fad5631b3d1692609f6dd77b64e8578321b7aeec07a026acd2c867c04a5", size = 1219263 }, - { url = "https://files.pythonhosted.org/packages/a5/d4/40082e8666cfdb24461855e9bb29fe77f063cc65a6c903291f2e5225f780/jiter-0.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15356b943e70ca7ab3b587ffaffadc0158467f6c4e0b491e52a0743c4bdf5ba1", size = 350364 }, - { url = "https://files.pythonhosted.org/packages/c4/09/09bc72dd143f76acd55e04c3a45b9f9ee3ed28e00b49924e3702ad041812/jiter-0.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53a7033a46141ff815518a6972d657c75d8f5946b9315e1c25b07e9677c1ff6c", size = 1146802 }, - { url = "https://files.pythonhosted.org/packages/5b/34/9d15a9c04d5760537b432134447bde94b936ec73dc922b4d14a48def2e1f/jiter-0.9.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:68cf519a6f00b8127f9be64a37e97e978094438abced5adebe088a98c64bdcff", size = 1256019 }, - { url = "https://files.pythonhosted.org/packages/8f/01/1fcd165fb28968a54bb46a209d5919f7649b96608eef7dc4622ea378b95a/jiter-0.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9098abdd34cd9ddeb04768cc4f5fc725ebd9a52978c488da74e58a837ce93506", size = 1257610 }, - { url = "https://files.pythonhosted.org/packages/9f/87/93ac6a57331dd90e4c896ac852bf8ce6b28b40dace4b9698a207dbb99af2/jiter-0.9.1-cp311-cp311-win32.whl", hash = "sha256:7179ce96aecd096af890dd57b84133e47a59fbde32a77734f09bafa6a4da619e", size = 214515 }, - { url = "https://files.pythonhosted.org/packages/bb/ee/3678b8a3bd5f6471d0a492540e7ff9c63db278d844214458ec5cfb22adb2/jiter-0.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:e6517f5b7b6f60fd77fc1099572f445be19553c6f61b907ab5b413fb7179663f", size = 212258 }, - { url = "https://files.pythonhosted.org/packages/ba/a7/5b3ce91b5bb83bf47e85ab2efda26a1706fb52498a2abe79df09af7dfa8f/jiter-0.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f330c5023ce4153ceb3e8abe76ecab8c5b525824bcec4e781791d044e5b5fc3a", size = 307494 }, - { url = "https://files.pythonhosted.org/packages/fd/9a/006ebbb5ab55fd9f47c219f9de7fdedd38694c158ddd6760a15f7a6fcdc8/jiter-0.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:77de4d2d529ece2d43fc0dbe90971e9e18f42ed6dd50b40fe232e799efb72c29", size = 312782 }, - { url = "https://files.pythonhosted.org/packages/17/da/a437705850c8cf6b8c93769ff6fcb3abcbfeb9c12b690c5f1631682d4286/jiter-0.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ed3eec217a70762a01ecfbecea27eda91d7d5792bdef41096d2c672a9e3c1fe", size = 1087076 }, - { url = "https://files.pythonhosted.org/packages/e6/8b/f463a03de974d437abc312a0ca6212e2b014b7023a880fd6956ebfde15c7/jiter-0.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d000bb8b9b3a90fb61ff864869461c56ad2dad5f0fa71127464cb65e69ec864b", size = 1118826 }, - { url = "https://files.pythonhosted.org/packages/6a/04/4d9289d8610f2b10886b4bd32b0c6e036fdeabc86cc9a902e50434a066bd/jiter-0.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3610aed85fad26d5e107ce4e246c236b612e539b382d490761aacc4aa5d7cdbf", size = 1213155 }, - { url = "https://files.pythonhosted.org/packages/f3/4c/851c0a7c95e333d5213558fc76d217a7760de8b704299c007537af49e1de/jiter-0.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae8f1f42f4b0ed244f88bb863d0777292e76e43ee2dc0dac4d63fe29bee183e5", size = 1215024 }, - { url = "https://files.pythonhosted.org/packages/8f/24/9c62f5775645715ded77a4cf03b9f3c36d4909ee35b07f65bb4ccaad4bfd/jiter-0.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2082da43e7b6174c3522a6905a9ee9187c9771e32cad7ab58360f189595a7c3f", size = 350280 }, - { url = "https://files.pythonhosted.org/packages/d9/79/54a4b1074f1f048ca822a2f4a738fa7b623203540a59ec99d0b0277c38ef/jiter-0.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d82b2b8bc089c4ebff99907bdb890730e05c58169d5493473c916518f8d29f5c", size = 1150978 }, - { url = "https://files.pythonhosted.org/packages/9c/1b/caaa8d274ba82486dfb582e32f431412f2e178344ebf6a231b8606c048fd/jiter-0.9.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8b7214d4064759ff34846311cabcf49715e8a7286a4431bc7444537ee2f21b1a", size = 1257583 }, - { url = "https://files.pythonhosted.org/packages/19/f7/a5f991075b16b76b15e4da7939243f373ff4369ce41145be428c7c43d905/jiter-0.9.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:136a635797b27aeb5cacea4d0ffeff5c80081089217c5891bd28968e5df97824", size = 1258268 }, - { url = "https://files.pythonhosted.org/packages/94/8f/6fabe1aa77637be629e73db2ee3059889b893c4be391f0e038b71948d208/jiter-0.9.1-cp312-cp312-win32.whl", hash = "sha256:5da9a4e2939c4af7617fe01f7e3978fba224d93def72bc748d173f148a8b637f", size = 214250 }, - { url = "https://files.pythonhosted.org/packages/7d/18/6f118d22acf5930d5a46c4f6853eead883af8c097d83e2a2971308864423/jiter-0.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:d1434a05965d0c1f033061f21553fef5c3a352f3e880a0f503e79e6b639db10c", size = 211070 }, - { url = "https://files.pythonhosted.org/packages/e2/36/4b5c7c96ce4795376e546bcabd96d8fe8667c9fdeb946523ca382cc30eaa/jiter-0.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cb0629af6a12804ace5f093884c2f14d5075d95951a086054e106cfdb6b8862f", size = 307047 }, - { url = "https://files.pythonhosted.org/packages/3e/20/7635fb02fe62cd90899dc1c64c972c1470106eede55ce35fc6e3868251af/jiter-0.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d15cc2b5602fb5a16689afb507b27c650167152203394efa429a5139553dd993", size = 311796 }, - { url = "https://files.pythonhosted.org/packages/e4/43/7e4a38c63b9f1a5795d406a7cf1e8a42af0e51d05d5c5b866708a345d49e/jiter-0.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffbf9279273b41fb8c4360ad2590a8eea82b36665728f57b0d7b095a904016d9", size = 1086812 }, - { url = "https://files.pythonhosted.org/packages/30/17/3d5ad7a1e12bb172040c2e206068ee766a320c6b6327a0a52a9c05bf4cd6/jiter-0.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fca2935783d4309eed77ed2acd625f93a07b79693f7d8e58e3c18ac8981e9ea", size = 1118218 }, - { url = "https://files.pythonhosted.org/packages/a0/f7/9f46d976a91f339898783962043c36b8c9fe103135f264ae25dddad9838e/jiter-0.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3f5f14d63924d3b226236c746ceb37f5ac9d3ce1251762819024f84904b4a0f", size = 1211346 }, - { url = "https://files.pythonhosted.org/packages/93/71/cf594ec8c76188b5e42fc4f00a9cdfb3f675631234f5a1ac5413fe6684cb/jiter-0.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0d43dcddb437096ac48e85f6be8355d806ab9246051f95263933fa5e18d026aa", size = 1214466 }, - { url = "https://files.pythonhosted.org/packages/e2/e5/efd89f27838ea9d8257c9bc8edd58a953e06ca304c7d2b397fdd2a932e51/jiter-0.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19773c6f730523effbca88c4a15658b481cf81e4c981fcd1212dd4beaa0cd37a", size = 350245 }, - { url = "https://files.pythonhosted.org/packages/b3/78/b7960c8a04d593687659007e6b7f911ef3f877eb11cd2503267ad5b2da0b/jiter-0.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:01fcc08b6d3e29562d72edfcd6c5b0aab30b964fb0c99ad8287c2dffeb6fd38c", size = 1149223 }, - { url = "https://files.pythonhosted.org/packages/65/60/4777b5a70febeece230593a82a69d0d19b5b6e36a8b3afcc4b43528c2657/jiter-0.9.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:448afc1a801a518ed438667229f380bb0b8503f379d170ac947575cb7e1e4edf", size = 1257025 }, - { url = "https://files.pythonhosted.org/packages/e8/c1/8fe3483537d85bc381bdab2a4952707d92944b1ac32074f7b33de188c2d0/jiter-0.9.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f321fb984ed7544e77346714a25ffa5bbefddd1adcc32c8fba49030a119a31c6", size = 1257882 }, - { url = "https://files.pythonhosted.org/packages/7b/1a/4453114fb7b3722f8d232b3c08114535e455d7d2d4d83b44cede53ed42ae/jiter-0.9.1-cp313-cp313-win32.whl", hash = "sha256:7db7c9a95d72668545606aeaf110549f4f42679eaa3ce5c32f8f26c1838550d8", size = 214946 }, - { url = "https://files.pythonhosted.org/packages/15/d0/237d7dbaaafb08a6f719c8495663b76d70d6c5880a02c7b092f21292458b/jiter-0.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:a6b750ef1201fe4c431f869705607ece4adaf592e497efb6bc4138efaebb4f59", size = 209888 }, - { url = "https://files.pythonhosted.org/packages/51/32/e90c89adbea8342b6e470f3be9c213b628ae3842810553df15d5afb386ce/jiter-0.9.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4096dba935aa2730c7642146b065855a0f5853fd9bbe22de9e3dd39fcacc37fe", size = 311645 }, - { url = "https://files.pythonhosted.org/packages/29/40/98fee5bab390c27d20ba82c73d12afd1db89aabeef641ae7629a31a7100f/jiter-0.9.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13ad975e0d9d2f7e54b30d9ae8e2e1c97be422e75606bddc67427721ad13cd1c", size = 352754 }, - { url = "https://files.pythonhosted.org/packages/9b/17/b0fa4ee5bdcb252b2407fc9528f11d8af717b7218455d23018cf314ccf6a/jiter-0.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f11992b20f8a2d336b98b31bff4d8bfcc4bd5aef7840594e32d6cb44fb9b96cf", size = 212573 }, - { url = "https://files.pythonhosted.org/packages/26/ca/1c7438d66969a13938266492de65daf752754ec59f2a3f3716027c7d708f/jiter-0.9.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:95065923a49ae387bab62b1bf5f798beb12e6fb4469a079fdd0ecad64b40b272", size = 313516 }, - { url = "https://files.pythonhosted.org/packages/e8/d9/3a6300309e312f8ed529ae57d565f69abdb520e4f12460cefa7996d0716c/jiter-0.9.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a179fbc5c7922844a673be35099a3036a7276dc63753c6c81a77c3cb525f2f8d", size = 308161 }, - { url = "https://files.pythonhosted.org/packages/b3/91/2aca15be38514daf8f1a1460fd9c4b652ed09148fe109520298858be7928/jiter-0.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd30dc5c0183d31faf30ce8279d723809c54b3fe6d95d922d4a4b31bc462799", size = 1086100 }, - { url = "https://files.pythonhosted.org/packages/9f/6f/f7ba3dfe7be08bf58939324e0bb4f4aa605eff7f2c2ac140a41221cf50a4/jiter-0.9.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9765512bdeae269843e6615377f48123432da247e18048d05e9c5685377c241c", size = 1118922 }, - { url = "https://files.pythonhosted.org/packages/b5/4e/b1f4d9bdba81de293e1b8672598300a9195cf3d77b0acc5f331a75695b58/jiter-0.9.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f15cdbdc1e1e89e0d9ea581de63e03975043a4b40ab87d5554fdc440357b771", size = 1212327 }, - { url = "https://files.pythonhosted.org/packages/3e/ab/e417aaf5a62067bd91c5f7ed4e5ab83bd46f349449adde1159ad8e2d3a21/jiter-0.9.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1a639b2cfe56b5b687c678ed45d68f46dfb922c2f338fdfb227eb500053929d", size = 1220860 }, - { url = "https://files.pythonhosted.org/packages/1e/50/c5ba756c641ca8ebc1e4ff07c03ce5c8ef5052b0238f514436f8de3c9fc4/jiter-0.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41955c9d83c8470de9cc64c97b04a3ffd2f32815bb2c4307f44d8e21542b74df", size = 344077 }, - { url = "https://files.pythonhosted.org/packages/c6/b3/bd7d8d4bad65aa1f4a20562233080054149785c0d7f7b9027e761335d882/jiter-0.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f26f6d42c330e26a6ba3471b390364faad96f3ca965a6c579957810b0c078efa", size = 1148785 }, - { url = "https://files.pythonhosted.org/packages/c0/12/bfd9a167709f96171312d1e0ae2c1be70a167abcc3bff6f3441967e3626a/jiter-0.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a23e01bd7e918f27f02d3df8721b8a395211070a8a65aeb353209b8c72720cf", size = 1255962 }, - { url = "https://files.pythonhosted.org/packages/5f/3c/3a79020862d2511b854b350bc9229cf228fd38b836e94f274ca940e22e95/jiter-0.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8a96ad217989dd9df661711c3fa2e6fb2601c4bbb482e89718110bdafbc16c9e", size = 1257561 }, - { url = "https://files.pythonhosted.org/packages/93/d3/7f6f8e57613d4947a872980befa6af19de9252e310ea4a512eed0fe1e064/jiter-0.9.1-cp38-cp38-win32.whl", hash = "sha256:4b180e7baa4747b3834c5a9202b1ba30dc64797f45236d9142cdb2a8807763cf", size = 215019 }, - { url = "https://files.pythonhosted.org/packages/9b/5d/b6f0cd60c8f702936f253644a92dee19e2c82010290e4607af462033351f/jiter-0.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:baf881de1fbc7b3343cce24f75a2ab6350e03fc13d16d00f452929788a6cdc3f", size = 199563 }, - { url = "https://files.pythonhosted.org/packages/4f/3a/a8a4768af26578c87894bb130bcd6fb6c97f4cb36ed7a20a664412d41935/jiter-0.9.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:ec95aa1b433c50b2b129456b4680b239ec93206ea3f86cfd41b6a70be5beb2f3", size = 313942 }, - { url = "https://files.pythonhosted.org/packages/63/74/05977891db48000d985a5f573493c43adf0f190eada670e51b92c9ed9139/jiter-0.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5d92cb50d135dbdd33b638fa2e0c6af25e1d635d38da13aa9ab05d021fb0c869", size = 308160 }, - { url = "https://files.pythonhosted.org/packages/21/54/75f529e90442c8ad41acd8cf08323a4f3dcaa105710b2c8a1fda56e3a462/jiter-0.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b146dc2464f1d96007271d08bdf79288a5f1aa4aae5329eb79dcffb1181c703e", size = 1086503 }, - { url = "https://files.pythonhosted.org/packages/bf/fa/02532a7ce7b712c576125d4f2614e77bc897c95b2b15e21ee25f42b3ff34/jiter-0.9.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fcf20ba858658ecd54b4710172d92009afa66d41d967c86d11607592a3c220fa", size = 1120444 }, - { url = "https://files.pythonhosted.org/packages/91/c2/ab8cebaea6f2691eddcc5b6c67deb1399adbd85f12ad836f7cd77be78bf8/jiter-0.9.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:147fccc44bebdb672d4c601e9312730488b840d415e201e89c8ea0929a63dacf", size = 1212370 }, - { url = "https://files.pythonhosted.org/packages/13/e3/90dddb7877b67cc0e1ddb864c2ca74314def26ff6542431a6e3061e0f805/jiter-0.9.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a428061aae26efaa6fb690ef9e7d6224aefe4eef7524165d073beb3cdad75f6f", size = 1221210 }, - { url = "https://files.pythonhosted.org/packages/81/76/90ee847519a94a4a1a8bad7addce7019f424aea03c55eacf068469226760/jiter-0.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7164d92bb901784bd3c098ac0b0beae4306ea6c741dbd3a375449a8affc5366", size = 353774 }, - { url = "https://files.pythonhosted.org/packages/59/a6/614a5d672d4b9c6bc9ad34579f0522577a0a78cc265069fca96543a832ca/jiter-0.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:93049a562233808914a2b938b0c745d7049db1667b3f42f0f5cf48e617393ba5", size = 1148581 }, - { url = "https://files.pythonhosted.org/packages/2d/94/c100147c310361fa83e25c4c6ce17723532147580252962b89e6085795c2/jiter-0.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f6dcf2cb16cc15d82a018e20eeaf169e6f6cd8c426f4c312ebe11710c623bed2", size = 1256636 }, - { url = "https://files.pythonhosted.org/packages/51/9a/dc82e218ba839052899df555e34f16b8ad1d7da9c01be208f65a5bf0083c/jiter-0.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2da9d485a7c526817cde9ff8b3394fa50ff5b782b86b6896378a3ba8844550f2", size = 1258099 }, - { url = "https://files.pythonhosted.org/packages/58/d5/d853e069624038950265ac0e877985b249049b624e925dab6cd11035140c/jiter-0.9.1-cp39-cp39-win32.whl", hash = "sha256:ea58c155d827d24e5ba8d7958ec4738b26be0894c0881a91d88b39ff48bb06c9", size = 214611 }, - { url = "https://files.pythonhosted.org/packages/cb/8d/7b6b1ee6e3d9d1a06237bbdfe4c6bb21baf323d3f70a0cc8f203de40c6b2/jiter-0.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:be2e911ecdb438951290c2079fe4190e7cc5be9e849df4caeb085b83ed620ff6", size = 211171 }, -] - -[[package]] -name = "jiter" -version = "0.12.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/45/9d/e0660989c1370e25848bb4c52d061c71837239738ad937e83edca174c273/jiter-0.12.0.tar.gz", hash = "sha256:64dfcd7d5c168b38d3f9f8bba7fc639edb3418abcc74f22fdbe6b8938293f30b", size = 168294 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/91/13cb9505f7be74a933f37da3af22e029f6ba64f5669416cb8b2774bc9682/jiter-0.12.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e7acbaba9703d5de82a2c98ae6a0f59ab9770ab5af5fa35e43a303aee962cf65", size = 316652 }, - { url = "https://files.pythonhosted.org/packages/4e/76/4e9185e5d9bb4e482cf6dec6410d5f78dfeb374cfcecbbe9888d07c52daa/jiter-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:364f1a7294c91281260364222f535bc427f56d4de1d8ffd718162d21fbbd602e", size = 319829 }, - { url = "https://files.pythonhosted.org/packages/86/af/727de50995d3a153138139f259baae2379d8cb0522c0c00419957bc478a6/jiter-0.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85ee4d25805d4fb23f0a5167a962ef8e002dbfb29c0989378488e32cf2744b62", size = 350568 }, - { url = "https://files.pythonhosted.org/packages/6a/c1/d6e9f4b7a3d5ac63bcbdfddeb50b2dcfbdc512c86cffc008584fdc350233/jiter-0.12.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:796f466b7942107eb889c08433b6e31b9a7ed31daceaecf8af1be26fb26c0ca8", size = 369052 }, - { url = "https://files.pythonhosted.org/packages/eb/be/00824cd530f30ed73fa8a4f9f3890a705519e31ccb9e929f1e22062e7c76/jiter-0.12.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35506cb71f47dba416694e67af996bbdefb8e3608f1f78799c2e1f9058b01ceb", size = 481585 }, - { url = "https://files.pythonhosted.org/packages/74/b6/2ad7990dff9504d4b5052eef64aa9574bd03d722dc7edced97aad0d47be7/jiter-0.12.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:726c764a90c9218ec9e4f99a33d6bf5ec169163f2ca0fc21b654e88c2abc0abc", size = 380541 }, - { url = "https://files.pythonhosted.org/packages/b5/c7/f3c26ecbc1adbf1db0d6bba99192143d8fe8504729d9594542ecc4445784/jiter-0.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa47810c5565274810b726b0dc86d18dce5fd17b190ebdc3890851d7b2a0e74", size = 364423 }, - { url = "https://files.pythonhosted.org/packages/18/51/eac547bf3a2d7f7e556927278e14c56a0604b8cddae75815d5739f65f81d/jiter-0.12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8ec0259d3f26c62aed4d73b198c53e316ae11f0f69c8fbe6682c6dcfa0fcce2", size = 389958 }, - { url = "https://files.pythonhosted.org/packages/2c/1f/9ca592e67175f2db156cff035e0d817d6004e293ee0c1d73692d38fcb596/jiter-0.12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:79307d74ea83465b0152fa23e5e297149506435535282f979f18b9033c0bb025", size = 522084 }, - { url = "https://files.pythonhosted.org/packages/83/ff/597d9cdc3028f28224f53e1a9d063628e28b7a5601433e3196edda578cdd/jiter-0.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf6e6dd18927121fec86739f1a8906944703941d000f0639f3eb6281cc601dca", size = 513054 }, - { url = "https://files.pythonhosted.org/packages/24/6d/1970bce1351bd02e3afcc5f49e4f7ef3dabd7fb688f42be7e8091a5b809a/jiter-0.12.0-cp310-cp310-win32.whl", hash = "sha256:b6ae2aec8217327d872cbfb2c1694489057b9433afce447955763e6ab015b4c4", size = 206368 }, - { url = "https://files.pythonhosted.org/packages/e3/6b/eb1eb505b2d86709b59ec06681a2b14a94d0941db091f044b9f0e16badc0/jiter-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c7f49ce90a71e44f7e1aa9e7ec415b9686bbc6a5961e57eab511015e6759bc11", size = 204847 }, - { url = "https://files.pythonhosted.org/packages/32/f9/eaca4633486b527ebe7e681c431f529b63fe2709e7c5242fc0f43f77ce63/jiter-0.12.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8f8a7e317190b2c2d60eb2e8aa835270b008139562d70fe732e1c0020ec53c9", size = 316435 }, - { url = "https://files.pythonhosted.org/packages/10/c1/40c9f7c22f5e6ff715f28113ebaba27ab85f9af2660ad6e1dd6425d14c19/jiter-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2218228a077e784c6c8f1a8e5d6b8cb1dea62ce25811c356364848554b2056cd", size = 320548 }, - { url = "https://files.pythonhosted.org/packages/6b/1b/efbb68fe87e7711b00d2cfd1f26bb4bfc25a10539aefeaa7727329ffb9cb/jiter-0.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9354ccaa2982bf2188fd5f57f79f800ef622ec67beb8329903abf6b10da7d423", size = 351915 }, - { url = "https://files.pythonhosted.org/packages/15/2d/c06e659888c128ad1e838123d0638f0efad90cc30860cb5f74dd3f2fc0b3/jiter-0.12.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2607185ea89b4af9a604d4c7ec40e45d3ad03ee66998b031134bc510232bb7", size = 368966 }, - { url = "https://files.pythonhosted.org/packages/6b/20/058db4ae5fb07cf6a4ab2e9b9294416f606d8e467fb74c2184b2a1eeacba/jiter-0.12.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a585a5e42d25f2e71db5f10b171f5e5ea641d3aa44f7df745aa965606111cc2", size = 482047 }, - { url = "https://files.pythonhosted.org/packages/49/bb/dc2b1c122275e1de2eb12905015d61e8316b2f888bdaac34221c301495d6/jiter-0.12.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd9e21d34edff5a663c631f850edcb786719c960ce887a5661e9c828a53a95d9", size = 380835 }, - { url = "https://files.pythonhosted.org/packages/23/7d/38f9cd337575349de16da575ee57ddb2d5a64d425c9367f5ef9e4612e32e/jiter-0.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4a612534770470686cd5431478dc5a1b660eceb410abade6b1b74e320ca98de6", size = 364587 }, - { url = "https://files.pythonhosted.org/packages/f0/a3/b13e8e61e70f0bb06085099c4e2462647f53cc2ca97614f7fedcaa2bb9f3/jiter-0.12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3985aea37d40a908f887b34d05111e0aae822943796ebf8338877fee2ab67725", size = 390492 }, - { url = "https://files.pythonhosted.org/packages/07/71/e0d11422ed027e21422f7bc1883c61deba2d9752b720538430c1deadfbca/jiter-0.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b1207af186495f48f72529f8d86671903c8c10127cac6381b11dddc4aaa52df6", size = 522046 }, - { url = "https://files.pythonhosted.org/packages/9f/59/b968a9aa7102a8375dbbdfbd2aeebe563c7e5dddf0f47c9ef1588a97e224/jiter-0.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef2fb241de583934c9915a33120ecc06d94aa3381a134570f59eed784e87001e", size = 513392 }, - { url = "https://files.pythonhosted.org/packages/ca/e4/7df62002499080dbd61b505c5cb351aa09e9959d176cac2aa8da6f93b13b/jiter-0.12.0-cp311-cp311-win32.whl", hash = "sha256:453b6035672fecce8007465896a25b28a6b59cfe8fbc974b2563a92f5a92a67c", size = 206096 }, - { url = "https://files.pythonhosted.org/packages/bb/60/1032b30ae0572196b0de0e87dce3b6c26a1eff71aad5fe43dee3082d32e0/jiter-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:ca264b9603973c2ad9435c71a8ec8b49f8f715ab5ba421c85a51cde9887e421f", size = 204899 }, - { url = "https://files.pythonhosted.org/packages/49/d5/c145e526fccdb834063fb45c071df78b0cc426bbaf6de38b0781f45d956f/jiter-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:cb00ef392e7d684f2754598c02c409f376ddcef857aae796d559e6cacc2d78a5", size = 188070 }, - { url = "https://files.pythonhosted.org/packages/92/c9/5b9f7b4983f1b542c64e84165075335e8a236fa9e2ea03a0c79780062be8/jiter-0.12.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:305e061fa82f4680607a775b2e8e0bcb071cd2205ac38e6ef48c8dd5ebe1cf37", size = 314449 }, - { url = "https://files.pythonhosted.org/packages/98/6e/e8efa0e78de00db0aee82c0cf9e8b3f2027efd7f8a71f859d8f4be8e98ef/jiter-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5c1860627048e302a528333c9307c818c547f214d8659b0705d2195e1a94b274", size = 319855 }, - { url = "https://files.pythonhosted.org/packages/20/26/894cd88e60b5d58af53bec5c6759d1292bd0b37a8b5f60f07abf7a63ae5f/jiter-0.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:df37577a4f8408f7e0ec3205d2a8f87672af8f17008358063a4d6425b6081ce3", size = 350171 }, - { url = "https://files.pythonhosted.org/packages/f5/27/a7b818b9979ac31b3763d25f3653ec3a954044d5e9f5d87f2f247d679fd1/jiter-0.12.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fdd787356c1c13a4f40b43c2156276ef7a71eb487d98472476476d803fb2cf", size = 365590 }, - { url = "https://files.pythonhosted.org/packages/ba/7e/e46195801a97673a83746170b17984aa8ac4a455746354516d02ca5541b4/jiter-0.12.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1eb5db8d9c65b112aacf14fcd0faae9913d07a8afea5ed06ccdd12b724e966a1", size = 479462 }, - { url = "https://files.pythonhosted.org/packages/ca/75/f833bfb009ab4bd11b1c9406d333e3b4357709ed0570bb48c7c06d78c7dd/jiter-0.12.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73c568cc27c473f82480abc15d1301adf333a7ea4f2e813d6a2c7d8b6ba8d0df", size = 378983 }, - { url = "https://files.pythonhosted.org/packages/71/b3/7a69d77943cc837d30165643db753471aff5df39692d598da880a6e51c24/jiter-0.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4321e8a3d868919bcb1abb1db550d41f2b5b326f72df29e53b2df8b006eb9403", size = 361328 }, - { url = "https://files.pythonhosted.org/packages/b0/ac/a78f90caf48d65ba70d8c6efc6f23150bc39dc3389d65bbec2a95c7bc628/jiter-0.12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0a51bad79f8cc9cac2b4b705039f814049142e0050f30d91695a2d9a6611f126", size = 386740 }, - { url = "https://files.pythonhosted.org/packages/39/b6/5d31c2cc8e1b6a6bcf3c5721e4ca0a3633d1ab4754b09bc7084f6c4f5327/jiter-0.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2a67b678f6a5f1dd6c36d642d7db83e456bc8b104788262aaefc11a22339f5a9", size = 520875 }, - { url = "https://files.pythonhosted.org/packages/30/b5/4df540fae4e9f68c54b8dab004bd8c943a752f0b00efd6e7d64aa3850339/jiter-0.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efe1a211fe1fd14762adea941e3cfd6c611a136e28da6c39272dbb7a1bbe6a86", size = 511457 }, - { url = "https://files.pythonhosted.org/packages/07/65/86b74010e450a1a77b2c1aabb91d4a91dd3cd5afce99f34d75fd1ac64b19/jiter-0.12.0-cp312-cp312-win32.whl", hash = "sha256:d779d97c834b4278276ec703dc3fc1735fca50af63eb7262f05bdb4e62203d44", size = 204546 }, - { url = "https://files.pythonhosted.org/packages/1c/c7/6659f537f9562d963488e3e55573498a442503ced01f7e169e96a6110383/jiter-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:e8269062060212b373316fe69236096aaf4c49022d267c6736eebd66bbbc60bb", size = 205196 }, - { url = "https://files.pythonhosted.org/packages/21/f4/935304f5169edadfec7f9c01eacbce4c90bb9a82035ac1de1f3bd2d40be6/jiter-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:06cb970936c65de926d648af0ed3d21857f026b1cf5525cb2947aa5e01e05789", size = 186100 }, - { url = "https://files.pythonhosted.org/packages/3d/a6/97209693b177716e22576ee1161674d1d58029eb178e01866a0422b69224/jiter-0.12.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:6cc49d5130a14b732e0612bc76ae8db3b49898732223ef8b7599aa8d9810683e", size = 313658 }, - { url = "https://files.pythonhosted.org/packages/06/4d/125c5c1537c7d8ee73ad3d530a442d6c619714b95027143f1b61c0b4dfe0/jiter-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37f27a32ce36364d2fa4f7fdc507279db604d27d239ea2e044c8f148410defe1", size = 318605 }, - { url = "https://files.pythonhosted.org/packages/99/bf/a840b89847885064c41a5f52de6e312e91fa84a520848ee56c97e4fa0205/jiter-0.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bbc0944aa3d4b4773e348cda635252824a78f4ba44328e042ef1ff3f6080d1cf", size = 349803 }, - { url = "https://files.pythonhosted.org/packages/8a/88/e63441c28e0db50e305ae23e19c1d8fae012d78ed55365da392c1f34b09c/jiter-0.12.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da25c62d4ee1ffbacb97fac6dfe4dcd6759ebdc9015991e92a6eae5816287f44", size = 365120 }, - { url = "https://files.pythonhosted.org/packages/0a/7c/49b02714af4343970eb8aca63396bc1c82fa01197dbb1e9b0d274b550d4e/jiter-0.12.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:048485c654b838140b007390b8182ba9774621103bd4d77c9c3f6f117474ba45", size = 479918 }, - { url = "https://files.pythonhosted.org/packages/69/ba/0a809817fdd5a1db80490b9150645f3aae16afad166960bcd562be194f3b/jiter-0.12.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:635e737fbb7315bef0037c19b88b799143d2d7d3507e61a76751025226b3ac87", size = 379008 }, - { url = "https://files.pythonhosted.org/packages/5f/c3/c9fc0232e736c8877d9e6d83d6eeb0ba4e90c6c073835cc2e8f73fdeef51/jiter-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e017c417b1ebda911bd13b1e40612704b1f5420e30695112efdbed8a4b389ed", size = 361785 }, - { url = "https://files.pythonhosted.org/packages/96/61/61f69b7e442e97ca6cd53086ddc1cf59fb830549bc72c0a293713a60c525/jiter-0.12.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:89b0bfb8b2bf2351fba36bb211ef8bfceba73ef58e7f0c68fb67b5a2795ca2f9", size = 386108 }, - { url = "https://files.pythonhosted.org/packages/e9/2e/76bb3332f28550c8f1eba3bf6e5efe211efda0ddbbaf24976bc7078d42a5/jiter-0.12.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:f5aa5427a629a824a543672778c9ce0c5e556550d1569bb6ea28a85015287626", size = 519937 }, - { url = "https://files.pythonhosted.org/packages/84/d6/fa96efa87dc8bff2094fb947f51f66368fa56d8d4fc9e77b25d7fbb23375/jiter-0.12.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed53b3d6acbcb0fd0b90f20c7cb3b24c357fe82a3518934d4edfa8c6898e498c", size = 510853 }, - { url = "https://files.pythonhosted.org/packages/8a/28/93f67fdb4d5904a708119a6ab58a8f1ec226ff10a94a282e0215402a8462/jiter-0.12.0-cp313-cp313-win32.whl", hash = "sha256:4747de73d6b8c78f2e253a2787930f4fffc68da7fa319739f57437f95963c4de", size = 204699 }, - { url = "https://files.pythonhosted.org/packages/c4/1f/30b0eb087045a0abe2a5c9c0c0c8da110875a1d3be83afd4a9a4e548be3c/jiter-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:e25012eb0c456fcc13354255d0338cd5397cce26c77b2832b3c4e2e255ea5d9a", size = 204258 }, - { url = "https://files.pythonhosted.org/packages/2c/f4/2b4daf99b96bce6fc47971890b14b2a36aef88d7beb9f057fafa032c6141/jiter-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:c97b92c54fe6110138c872add030a1f99aea2401ddcdaa21edf74705a646dd60", size = 185503 }, - { url = "https://files.pythonhosted.org/packages/39/ca/67bb15a7061d6fe20b9b2a2fd783e296a1e0f93468252c093481a2f00efa/jiter-0.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53839b35a38f56b8be26a7851a48b89bc47e5d88e900929df10ed93b95fea3d6", size = 317965 }, - { url = "https://files.pythonhosted.org/packages/18/af/1788031cd22e29c3b14bc6ca80b16a39a0b10e611367ffd480c06a259831/jiter-0.12.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f669548e55c91ab47fef8bddd9c954dab1938644e715ea49d7e117015110a4", size = 345831 }, - { url = "https://files.pythonhosted.org/packages/05/17/710bf8472d1dff0d3caf4ced6031060091c1320f84ee7d5dcbed1f352417/jiter-0.12.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:351d54f2b09a41600ffea43d081522d792e81dcfb915f6d2d242744c1cc48beb", size = 361272 }, - { url = "https://files.pythonhosted.org/packages/fb/f1/1dcc4618b59761fef92d10bcbb0b038b5160be653b003651566a185f1a5c/jiter-0.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2a5e90604620f94bf62264e7c2c038704d38217b7465b863896c6d7c902b06c7", size = 204604 }, - { url = "https://files.pythonhosted.org/packages/d9/32/63cb1d9f1c5c6632a783c0052cde9ef7ba82688f7065e2f0d5f10a7e3edb/jiter-0.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:88ef757017e78d2860f96250f9393b7b577b06a956ad102c29c8237554380db3", size = 185628 }, - { url = "https://files.pythonhosted.org/packages/a8/99/45c9f0dbe4a1416b2b9a8a6d1236459540f43d7fb8883cff769a8db0612d/jiter-0.12.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c46d927acd09c67a9fb1416df45c5a04c27e83aae969267e98fba35b74e99525", size = 312478 }, - { url = "https://files.pythonhosted.org/packages/4c/a7/54ae75613ba9e0f55fcb0bc5d1f807823b5167cc944e9333ff322e9f07dd/jiter-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:774ff60b27a84a85b27b88cd5583899c59940bcc126caca97eb2a9df6aa00c49", size = 318706 }, - { url = "https://files.pythonhosted.org/packages/59/31/2aa241ad2c10774baf6c37f8b8e1f39c07db358f1329f4eb40eba179c2a2/jiter-0.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5433fab222fb072237df3f637d01b81f040a07dcac1cb4a5c75c7aa9ed0bef1", size = 351894 }, - { url = "https://files.pythonhosted.org/packages/54/4f/0f2759522719133a9042781b18cc94e335b6d290f5e2d3e6899d6af933e3/jiter-0.12.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8c593c6e71c07866ec6bfb790e202a833eeec885022296aff6b9e0b92d6a70e", size = 365714 }, - { url = "https://files.pythonhosted.org/packages/dc/6f/806b895f476582c62a2f52c453151edd8a0fde5411b0497baaa41018e878/jiter-0.12.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90d32894d4c6877a87ae00c6b915b609406819dce8bc0d4e962e4de2784e567e", size = 478989 }, - { url = "https://files.pythonhosted.org/packages/86/6c/012d894dc6e1033acd8db2b8346add33e413ec1c7c002598915278a37f79/jiter-0.12.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:798e46eed9eb10c3adbbacbd3bdb5ecd4cf7064e453d00dbef08802dae6937ff", size = 378615 }, - { url = "https://files.pythonhosted.org/packages/87/30/d718d599f6700163e28e2c71c0bbaf6dace692e7df2592fd793ac9276717/jiter-0.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3f1368f0a6719ea80013a4eb90ba72e75d7ea67cfc7846db2ca504f3df0169a", size = 364745 }, - { url = "https://files.pythonhosted.org/packages/8f/85/315b45ce4b6ddc7d7fceca24068543b02bdc8782942f4ee49d652e2cc89f/jiter-0.12.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65f04a9d0b4406f7e51279710b27484af411896246200e461d80d3ba0caa901a", size = 386502 }, - { url = "https://files.pythonhosted.org/packages/74/0b/ce0434fb40c5b24b368fe81b17074d2840748b4952256bab451b72290a49/jiter-0.12.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:fd990541982a24281d12b67a335e44f117e4c6cbad3c3b75c7dea68bf4ce3a67", size = 519845 }, - { url = "https://files.pythonhosted.org/packages/e8/a3/7a7a4488ba052767846b9c916d208b3ed114e3eb670ee984e4c565b9cf0d/jiter-0.12.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b111b0e9152fa7df870ecaebb0bd30240d9f7fff1f2003bcb4ed0f519941820b", size = 510701 }, - { url = "https://files.pythonhosted.org/packages/c3/16/052ffbf9d0467b70af24e30f91e0579e13ded0c17bb4a8eb2aed3cb60131/jiter-0.12.0-cp314-cp314-win32.whl", hash = "sha256:a78befb9cc0a45b5a5a0d537b06f8544c2ebb60d19d02c41ff15da28a9e22d42", size = 205029 }, - { url = "https://files.pythonhosted.org/packages/e4/18/3cf1f3f0ccc789f76b9a754bdb7a6977e5d1d671ee97a9e14f7eb728d80e/jiter-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:e1fe01c082f6aafbe5c8faf0ff074f38dfb911d53f07ec333ca03f8f6226debf", size = 204960 }, - { url = "https://files.pythonhosted.org/packages/02/68/736821e52ecfdeeb0f024b8ab01b5a229f6b9293bbdb444c27efade50b0f/jiter-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:d72f3b5a432a4c546ea4bedc84cce0c3404874f1d1676260b9c7f048a9855451", size = 185529 }, - { url = "https://files.pythonhosted.org/packages/30/61/12ed8ee7a643cce29ac97c2281f9ce3956eb76b037e88d290f4ed0d41480/jiter-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e6ded41aeba3603f9728ed2b6196e4df875348ab97b28fc8afff115ed42ba7a7", size = 318974 }, - { url = "https://files.pythonhosted.org/packages/2d/c6/f3041ede6d0ed5e0e79ff0de4c8f14f401bbf196f2ef3971cdbe5fd08d1d/jiter-0.12.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a947920902420a6ada6ad51892082521978e9dd44a802663b001436e4b771684", size = 345932 }, - { url = "https://files.pythonhosted.org/packages/d5/5d/4d94835889edd01ad0e2dbfc05f7bdfaed46292e7b504a6ac7839aa00edb/jiter-0.12.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:add5e227e0554d3a52cf390a7635edaffdf4f8fce4fdbcef3cc2055bb396a30c", size = 367243 }, - { url = "https://files.pythonhosted.org/packages/fd/76/0051b0ac2816253a99d27baf3dda198663aff882fa6ea7deeb94046da24e/jiter-0.12.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9b1cda8fcb736250d7e8711d4580ebf004a46771432be0ae4796944b5dfa5d", size = 479315 }, - { url = "https://files.pythonhosted.org/packages/70/ae/83f793acd68e5cb24e483f44f482a1a15601848b9b6f199dacb970098f77/jiter-0.12.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:deeb12a2223fe0135c7ff1356a143d57f95bbf1f4a66584f1fc74df21d86b993", size = 380714 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/4808a88338ad2c228b1126b93fcd8ba145e919e886fe910d578230dabe3b/jiter-0.12.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c596cc0f4cb574877550ce4ecd51f8037469146addd676d7c1a30ebe6391923f", size = 365168 }, - { url = "https://files.pythonhosted.org/packages/0c/d4/04619a9e8095b42aef436b5aeb4c0282b4ff1b27d1db1508df9f5dc82750/jiter-0.12.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ab4c823b216a4aeab3fdbf579c5843165756bd9ad87cc6b1c65919c4715f783", size = 387893 }, - { url = "https://files.pythonhosted.org/packages/17/ea/d3c7e62e4546fdc39197fa4a4315a563a89b95b6d54c0d25373842a59cbe/jiter-0.12.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e427eee51149edf962203ff8db75a7514ab89be5cb623fb9cea1f20b54f1107b", size = 520828 }, - { url = "https://files.pythonhosted.org/packages/cc/0b/c6d3562a03fd767e31cb119d9041ea7958c3c80cb3d753eafb19b3b18349/jiter-0.12.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:edb868841f84c111255ba5e80339d386d937ec1fdce419518ce1bd9370fac5b6", size = 511009 }, - { url = "https://files.pythonhosted.org/packages/aa/51/2cb4468b3448a8385ebcd15059d325c9ce67df4e2758d133ab9442b19834/jiter-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8bbcfe2791dfdb7c5e48baf646d37a6a3dcb5a97a032017741dea9f817dca183", size = 205110 }, - { url = "https://files.pythonhosted.org/packages/b2/c5/ae5ec83dec9c2d1af805fd5fe8f74ebded9c8670c5210ec7820ce0dbeb1e/jiter-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2fa940963bf02e1d8226027ef461e36af472dea85d36054ff835aeed944dd873", size = 205223 }, - { url = "https://files.pythonhosted.org/packages/97/9a/3c5391907277f0e55195550cf3fa8e293ae9ee0c00fb402fec1e38c0c82f/jiter-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:506c9708dd29b27288f9f8f1140c3cb0e3d8ddb045956d7757b1fa0e0f39a473", size = 185564 }, - { url = "https://files.pythonhosted.org/packages/7d/da/3e1fbd1f03f89ff0b4469d481be0b5cf2880c8e7b56fd80303b3ab5ae52d/jiter-0.12.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c9d28b218d5f9e5f69a0787a196322a5056540cb378cac8ff542b4fa7219966c", size = 319378 }, - { url = "https://files.pythonhosted.org/packages/c7/4e/e07d69285e9e19a153050a6d281d2f0968600753a8fed8a3a141d6ffc140/jiter-0.12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d0ee12028daf8cfcf880dd492349a122a64f42c059b6c62a2b0c96a83a8da820", size = 312195 }, - { url = "https://files.pythonhosted.org/packages/2d/82/1f1cb5231b36af9f3d6d5b6030e70110faf14fd143419fc5fe7d852e691a/jiter-0.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b135ebe757a82d67ed2821526e72d0acf87dd61f6013e20d3c45b8048af927b", size = 352777 }, - { url = "https://files.pythonhosted.org/packages/6a/5e/728393bbbc99b31e8f7a4fdd8fa55e455a0a9648f79097d9088baf1f676f/jiter-0.12.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15d7fafb81af8a9e3039fc305529a61cd933eecee33b4251878a1c89859552a3", size = 370738 }, - { url = "https://files.pythonhosted.org/packages/30/08/ac92f0df7b14ac82f2fe0a382a8000e600ab90af95798d4a7db0c1bd0736/jiter-0.12.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92d1f41211d8a8fe412faad962d424d334764c01dac6691c44691c2e4d3eedaf", size = 483744 }, - { url = "https://files.pythonhosted.org/packages/7e/f4/dbfa4e759a2b82e969a14c3d0a91b176f1ed94717183a2f495cf94a651b9/jiter-0.12.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a64a48d7c917b8f32f25c176df8749ecf08cec17c466114727efe7441e17f6d", size = 382888 }, - { url = "https://files.pythonhosted.org/packages/6c/d9/b86fff7f748b0bb54222a8f132ffaf4d1be56b4591fa76d3cfdd701a33e5/jiter-0.12.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:122046f3b3710b85de99d9aa2f3f0492a8233a2f54a64902b096efc27ea747b5", size = 366465 }, - { url = "https://files.pythonhosted.org/packages/93/3c/1152d8b433317a568927e13c1b125c680e6c058ff5d304833be8469bd4f2/jiter-0.12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:27ec39225e03c32c6b863ba879deb427882f243ae46f0d82d68b695fa5b48b40", size = 392603 }, - { url = "https://files.pythonhosted.org/packages/6e/92/ff19d8fb87f3f9438eb7464862c8d0126455bc046b345d59b21443640c62/jiter-0.12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:26b9e155ddc132225a39b1995b3b9f0fe0f79a6d5cbbeacf103271e7d309b404", size = 523780 }, - { url = "https://files.pythonhosted.org/packages/87/3a/4260e2d84e4a293c36d2a8e8b8dcd69609c671f3bd310e4625359217c517/jiter-0.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ab05b7c58e29bb9e60b70c2e0094c98df79a1e42e397b9bb6eaa989b7a66dd0", size = 514874 }, - { url = "https://files.pythonhosted.org/packages/2e/f7/574d2cb79e86feb035ade18c2254da71d04417555907c9df51dd6b183426/jiter-0.12.0-cp39-cp39-win32.whl", hash = "sha256:59f9f9df87ed499136db1c2b6c9efb902f964bed42a582ab7af413b6a293e7b0", size = 208329 }, - { url = "https://files.pythonhosted.org/packages/05/ce/50725ec39782d8c973f19ae2d7dd3d192d01332c7cbde48c75e16a3e85a9/jiter-0.12.0-cp39-cp39-win_amd64.whl", hash = "sha256:d3719596a1ebe7a48a498e8d5d0c4bf7553321d4c3eee1d620628d51351a3928", size = 206557 }, -] - -[[package]] -name = "jq" -version = "1.10.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/86/6935afb6c1789d4c6ba5343607e2d2f473069eaac29fac555dbbd154c2d7/jq-1.10.0.tar.gz", hash = "sha256:fc38803075dbf1867e1b4ed268fef501feecb0c50f3555985a500faedfa70f08", size = 2031308 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/13/18/0611ddff443f826931c6a6e13a4d6213d159a66c9e4e82db1300b856870f/jq-1.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9bba438d1813e537294e77f6f0ab3f4b23d3a0ae125187edf4827260a31341a0", size = 420781 }, - { url = "https://files.pythonhosted.org/packages/1b/0c/7e53f3fe1c8d99fd19ea6d741f4268cb0efbd0800b4b25d5aa512c7b474d/jq-1.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3eb6aed0d9882c43ae4c1757b72afc02063504f69d14eb12352c9b2813137c71", size = 426800 }, - { url = "https://files.pythonhosted.org/packages/3e/fd/4eefc552dfefcd11aef2a4e4a018050ff174afa5841438a61bab171335eb/jq-1.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c2a6a83f8b59dcb0b9a09f1e6b042e667923916767d0bee869e217d067f5f25", size = 724351 }, - { url = "https://files.pythonhosted.org/packages/2b/e9/1748212f0e7d5d1424ae3246f3ca0ce18629b020a83454a9b18cc5d84152/jq-1.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:731fa11ce06276365c51fe2e23821d33acf6c66e501acfc4dd95507be840dd39", size = 743455 }, - { url = "https://files.pythonhosted.org/packages/1c/20/effb5ee6a9dbdd0fc2979ebfa2f29baca3aea09e528a0962dbef711721e4/jq-1.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cd313711ad4b6158662a65e1de9a4e34e6f297cacaa2a563b1d7c37fd453770e", size = 732555 }, - { url = "https://files.pythonhosted.org/packages/f5/2c/cb833cc9d61d8c67996d3568f4eb855175aa04b177f1915883148b7f962b/jq-1.10.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:af859195c4a46adc52866cbc08a5d56fea86cbb8d18c9e02b95fea7d0b9c872d", size = 714972 }, - { url = "https://files.pythonhosted.org/packages/6d/35/2dff23341d12eee4b0b5fc0196529462320a540836062162c0b743435c0e/jq-1.10.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:801d01e2c933fa3da70ce18d73adb29c4fd07ebe0e2da3f39f79719357a60014", size = 739653 }, - { url = "https://files.pythonhosted.org/packages/ce/09/ffb7304ccd4a728f22ef6cbc8b6168143378524462ebc45900cd60d4af54/jq-1.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f6557f291f0b13db045b35401fa8b67b866fa1488e3a9703a1bcc5d156948c59", size = 739776 }, - { url = "https://files.pythonhosted.org/packages/d5/c3/48c47fd1276fd8c2ef6904a81f187b76bea8ef6876c99e5711e9dce385b6/jq-1.10.0-cp310-cp310-win32.whl", hash = "sha256:148a140c16c366c42c63e5a920dc8259ab62034c6f2c6b0f410df579fdf04654", size = 411525 }, - { url = "https://files.pythonhosted.org/packages/3f/f2/70332d975fd5d1e722eef7ad3e40a96392dacbbc0b4024ef2384b3a1df7f/jq-1.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:f8aa3f4eb6948be7b9b7c8eb19d4fbdaa2765227d21ea875898e2d20552ad749", size = 422859 }, - { url = "https://files.pythonhosted.org/packages/51/e5/d460e048de611e8b455e1be98cba67fb70ecb194de3ba4486dc9dfba88cb/jq-1.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1363930e8efe63a76e6be93ffc6fea6d9201ba13a21f8a9c7943e9c6a4184cf7", size = 421078 }, - { url = "https://files.pythonhosted.org/packages/b7/f2/3183dd18746ef068c8798940683ff1a42397ee6519e1c1ee608843d376a1/jq-1.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:850c99324fdb2e42a2056c27ec45af87b1bc764a14c94cdf011f6e21d885f032", size = 427232 }, - { url = "https://files.pythonhosted.org/packages/65/2e/a566e4b254862f92be66365488bb78994110f32f8d60f255873fdaa429a7/jq-1.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75aabeae63f36fe421c25cb793f5e166500400e443e7f6ce509261d06d4f8b5d", size = 739810 }, - { url = "https://files.pythonhosted.org/packages/c9/e2/ad805b9a263a89c5fde75f2aa31d252c39732b55ead67d269e775eabe8a0/jq-1.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b6e1f04da95c5057346954b24e65cb401cf9c64566e68c4263454717fcf464d", size = 754311 }, - { url = "https://files.pythonhosted.org/packages/a9/39/403924bd41a2365bc1ba39c99b2922b8e3f97abe6405d0e0911018df045c/jq-1.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ff2ac703b1bde9209f124aa7948012b77e93a40f858c24cf0bbd48f150c15e8", size = 745667 }, - { url = "https://files.pythonhosted.org/packages/d8/5b/9f9d5e748b810bfe79f61f7dc36ed1c5d7d68feca3928659d6dfbba50e6b/jq-1.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:83ae246c191e6c5363cb7987af10c4c3071ec6995424eb749d225fbb985d9e47", size = 737610 }, - { url = "https://files.pythonhosted.org/packages/12/d6/799a9f8a1588c0275411b7754cf5939dec8003978e3a71c54fb68894fc5b/jq-1.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:307ed7ac72c03843af46face4ec1b8238f6d0d68f5a37aab3b55d178c329ad34", size = 762500 }, - { url = "https://files.pythonhosted.org/packages/27/04/18f406ba70f7f78f9576baed53d0d84f3f02420c124d0843c1e7b16567f5/jq-1.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ded278e64dad446667656f7144cefe20cea16bb57cf6912ef6d1ddf3bddc9861", size = 763614 }, - { url = "https://files.pythonhosted.org/packages/dc/f8/accb3c72ece3164e7019910b387fd65fc1da805bc8b7dac4e676d48b852e/jq-1.10.0-cp311-cp311-win32.whl", hash = "sha256:5d5624d43c8597b06a4a2c5461d1577f29f23991472a5da88b742f7fa529c1d1", size = 410182 }, - { url = "https://files.pythonhosted.org/packages/a5/1d/2af863d11a5330b69af6cc875bb54ecf942da4909b75284afef7468e70b5/jq-1.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:08bf55484a20955264358823049ff8deb671bb0025d51707ec591b5eb18a94d7", size = 421735 }, - { url = "https://files.pythonhosted.org/packages/3e/d9/b9e2b7004a2cb646507c082ea5e975ac37e6265353ec4c24779a1701c54a/jq-1.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe636cfa95b7027e7b43da83ecfd61431c0de80c3e0aa4946534b087149dcb4c", size = 420103 }, - { url = "https://files.pythonhosted.org/packages/75/ad/d6780c218040789ed3ddbfa3b1743aaf824f80be5ebd7d5f885224c5bb08/jq-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:947fc7e1baaa7e95833b950e5a66b3e13a5cff028bff2d009b8c320124d9e69b", size = 426325 }, - { url = "https://files.pythonhosted.org/packages/e9/42/5cfc8de34e976112e1b835a83264c7a0bab2cf8f20dc703f1257aa9e07ea/jq-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9382f85a347623afa521c43f8f09439e68906fd5b3492016f969a29219796bb9", size = 738212 }, - { url = "https://files.pythonhosted.org/packages/84/0a/eff78a2329967bda38a98580c6fb77c59696b2b7d589e97db232ca42f5c4/jq-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c376aab525d0a1debe403d3bc2f19fda9473696a1eda56bafc88248fc4ae6e7e", size = 757068 }, - { url = "https://files.pythonhosted.org/packages/f3/62/353d4c0a9f363ccb2a9b5ea205f079a4ee43642622c25250d95c0fafb7ca/jq-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:206f230c67a46776f848858c66b9c377a8e40c2b16195552edd96fd7b45f9a52", size = 744259 }, - { url = "https://files.pythonhosted.org/packages/4f/46/0faead425cc3a720c7cd999146f4b5f50aaf394800457efb27746c10832c/jq-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06986456ebc95ccb9e9c2a1f0e842bc9d441225a554a9f9d4370ad95a19ac000", size = 740075 }, - { url = "https://files.pythonhosted.org/packages/10/0c/8e0823c5a329d735cff9f3746e0f7d74e7eea4ed9b0e75f90f942d1c455a/jq-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d02c0be958ddb4d9254ff251b045df2f8ee5995137702eeab4ffa81158bcdbe0", size = 766475 }, - { url = "https://files.pythonhosted.org/packages/06/0c/9b5aae9081fe6620915aa0e0ca76fd016e5b9d399b80c8615852413f4404/jq-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cf6fd2ebd2453e75ceef207d5a95a39fcbda371a9b8916db0bd42e8737a621", size = 770416 }, - { url = "https://files.pythonhosted.org/packages/d4/e7/8f4e1cc3102de31d71e6298bcbdb15d1439e2bc466f4dcf18bc3694ba61d/jq-1.10.0-cp312-cp312-win32.whl", hash = "sha256:655d75d54a343944a9b011f568156cdc29ae0b35d2fdeefb001f459a4e4fc313", size = 410113 }, - { url = "https://files.pythonhosted.org/packages/20/1f/6efe0a2b69910643b80d7da39fbded8225749dee4b79ebe23d522109a310/jq-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d67c2653ae41eab48f8888c213c9e1807b43167f26ac623c9f3e00989d3edee", size = 422316 }, - { url = "https://files.pythonhosted.org/packages/f2/fe/eeede83103e90e8f5fd9b610514a4c714957d6575e03987ebeb77aafeafa/jq-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b11d6e115ebad15d738d49932c3a8b9bb302b928e0fb79acc80987598d147a43", size = 419325 }, - { url = "https://files.pythonhosted.org/packages/09/12/8b39293715d7721b2999facd4a05ca3328fe4a68cf1c094667789867aac1/jq-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df278904c5727dfe5bc678131a0636d731cd944879d890adf2fc6de35214b19b", size = 425344 }, - { url = "https://files.pythonhosted.org/packages/ec/f4/ace0c853d4462f1d28798d5696619d2fb68c8e1db228ef5517365a0f3c1c/jq-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab4c1ec69fd7719fb1356e2ade7bd2b5a63d6f0eaf5a90fdc5c9f6145f0474ce", size = 735874 }, - { url = "https://files.pythonhosted.org/packages/2a/b0/7882035062771686bd7e62db019fa0900fd9a3720b7ad8f7af65ee628484/jq-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd24dc21c8afcbe5aa812878251cfafa6f1dc6e1126c35d460cc7e67eb331018", size = 754355 }, - { url = "https://files.pythonhosted.org/packages/df/7d/b759a764c5d05c6829e95733a8b26f7e9b14df245ec2a325c0de049393ca/jq-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c0d3e89cd239c340c3a54e145ddf52fe63de31866cb73368d22a66bfe7e823f", size = 742546 }, - { url = "https://files.pythonhosted.org/packages/ad/6b/483ddb82939d4f2f9b0486887666c67a966434cc8bc72acd851fc8063f50/jq-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76710b280e4c464395c3d8e656b849e2704bd06e950a4ebd767860572bbf67df", size = 738777 }, - { url = "https://files.pythonhosted.org/packages/0c/72/4d0fc965a8e57f55291763bb236a5aee91430f97c844ee328667b34af19e/jq-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b11a56f1fb6e2985fd3627dbd8a0637f62b1a704f7b19705733d461dafa26429", size = 765307 }, - { url = "https://files.pythonhosted.org/packages/0b/a6/aca82622d8d20ea02bbcac8aaa92daaadd55a18c2a3ca54b2e63d98336d2/jq-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ac05ae44d9aa1e462329e1510e0b5139ac4446de650c7bdfdab226aafdc978ec", size = 769830 }, - { url = "https://files.pythonhosted.org/packages/0e/e3/a19aeada32dde0839e3a4d77f2f0d63f2764c579b57f405ff4b91a58a8db/jq-1.10.0-cp313-cp313-win32.whl", hash = "sha256:0bad90f5734e2fc9d09c4116ae9102c357a4d75efa60a85758b0ba633774eddb", size = 410285 }, - { url = "https://files.pythonhosted.org/packages/d6/32/df4eb81cf371654d91b6779d3f0005e86519977e19068638c266a9c88af7/jq-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ec3fbca80a9dfb5349cdc2531faf14dd832e1847499513cf1fc477bcf46a479", size = 423094 }, - { url = "https://files.pythonhosted.org/packages/14/c0/dc3b7d23b0624b6f038facc4959b0ad4587bbc4ab3c50148725169aa8928/jq-1.10.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:09e6ca3095a3be59353a262c75f680a0934ac03e81d10b78d7eabcb9fb746543", size = 420347 }, - { url = "https://files.pythonhosted.org/packages/b0/1b/6aa5ec1e29d8d62105a998eb6ad73f0836a40cc4a08d8b25997261f9c5bb/jq-1.10.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57e7b81b69700ad6003f6d068ae5432fa54169e2c5b15a1f9073400d83c0115a", size = 733865 }, - { url = "https://files.pythonhosted.org/packages/47/ca/cc828c62ac2120945f54058392d2af0b55e63d92092596efe20ead3031c9/jq-1.10.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:36a959b8cff3796b42f51a0be5fa37126ee66fc822e29620485a229f6c9baec6", size = 750619 }, - { url = "https://files.pythonhosted.org/packages/8f/4a/2e91ad467bbfd4011514dbb7fdab00310091d8af0f923c485532b30859d3/jq-1.10.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f44bd6b9d03367a9e8a3f8f5c8343b572fbec9d148242f226e2a6f2eb459ba2b", size = 743560 }, - { url = "https://files.pythonhosted.org/packages/93/54/32f890b039d9062952b6e1c69b333163b731a529f840f580f8326b7f3ecb/jq-1.10.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:48fb2cfe083942e370876e865fad3836aedc1b06ef30a2376e53ab35d6a7f728", size = 725282 }, - { url = "https://files.pythonhosted.org/packages/cd/8d/38dbb2fa34770b670859fe5562b6aee98e9d841955bf360a391245a7c452/jq-1.10.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:d47fe013dc22c82b425ae5358729a3d38de4097dda28d63f591c8bdd97bae6cb", size = 751442 }, - { url = "https://files.pythonhosted.org/packages/e8/87/cad67a39df21520e80e22a1bfc512e6a713a1c047439791a0ec48b9c30b2/jq-1.10.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2886606f40f2127ed4bea2aa2d30653d485ed26075dd5b52fb933aa7ec7b23d3", size = 750454 }, - { url = "https://files.pythonhosted.org/packages/ef/82/5d3b645211466d078ff04736843ee36350b56c01e12f2eec11be90db9df6/jq-1.10.0-cp38-cp38-win32.whl", hash = "sha256:f1f277fd820246f0d80da2ddd39b6d5ea99b266c067abce34f1ff50bd3358477", size = 412424 }, - { url = "https://files.pythonhosted.org/packages/55/f2/35d03dfff0bbf2370cee4290acc8659f132f32dfee8603807f67da3ea29f/jq-1.10.0-cp38-cp38-win_amd64.whl", hash = "sha256:ab050dc7a6c204dde3a3d28e340f937e00cf69c8d3f7dd17e8c3ffef916784df", size = 424987 }, - { url = "https://files.pythonhosted.org/packages/dc/56/6dbb244c115464ac181ba1a5132e93142a8e085845593da020f627960a14/jq-1.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db746ec4f05a6622bca5785f58fa322f04c493de61c6761cbe5a61218babe3d9", size = 420974 }, - { url = "https://files.pythonhosted.org/packages/9b/43/2906fbe63662ad1af4deb71b2b9ce5e359b761a71d9c8f55a1f44aa51be6/jq-1.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bb9a6b5ff1e9d261ffae51aefbc66660bc1f5713339943aa95af7631062be163", size = 427119 }, - { url = "https://files.pythonhosted.org/packages/25/4d/640b203ac8771c79404a145c8cb21220a9f03f010a5c60e2e379f9c3dccc/jq-1.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6690eda99a5be16f28c6931918769af0c2d066257d4efedc7c4108cfbf4e242f", size = 726303 }, - { url = "https://files.pythonhosted.org/packages/d2/b1/72ebcfc99f89cb3b96f9dc1da7cb8ce167a975f0b74aae461aa56c4735f0/jq-1.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f4b28f04bb69eadb99c7ba3911246e6a200a886d82ae190a0af95037c427de6", size = 743462 }, - { url = "https://files.pythonhosted.org/packages/5d/1b/410fae0e3d5d0a05707c2ee5bf4f7489196ab9de08957d2f4b70e6070d55/jq-1.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7278f1dfc49387551e670133b363a3368eeac74672dd4b520b7da4f8e803058", size = 734358 }, - { url = "https://files.pythonhosted.org/packages/56/cf/8f4390643072a6d5b05581aa582c91eb353ce549de50e3c88786786a1b5f/jq-1.10.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28c16ca90940dfb8a2bd28b2d02cb2a546faa91db5b03f2cb71148b158fc098c", size = 715866 }, - { url = "https://files.pythonhosted.org/packages/3b/12/0f1c0802426128d40f50e9a341ea6173a6d0fc80e87c99914be126b62af2/jq-1.10.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f700d2aa1ef2b58c33df0a06ba58e68196cc9f81d1b6eb6baaa34e81fc0dbe6d", size = 741175 }, - { url = "https://files.pythonhosted.org/packages/9e/98/546d1f0012b0d0f25a2cbaf32a7bcd92d995b3b7d387039bf5ac1807ee25/jq-1.10.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cf18f3031da9727245529e16ad13ab8859d73cfe0bc0e09a79696038658c25d5", size = 740563 }, - { url = "https://files.pythonhosted.org/packages/f8/f7/3436d12228b7bfb8d59cf20467dfd2bf261bc11d113fe454f340990694ba/jq-1.10.0-cp39-cp39-win32.whl", hash = "sha256:31753d5b45b1806d1d7241a45cb262b1f3db8c0f1f4c618d5a02cbe227d2c436", size = 411795 }, - { url = "https://files.pythonhosted.org/packages/db/d3/b7e0b8b6057254618989f2c5883997f2545143295a07789f890c1cfa8625/jq-1.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:c4648684034ba5b975be9b0099ca230ef088c14eeffb3357b522d9cba02a05ea", size = 422907 }, - { url = "https://files.pythonhosted.org/packages/f2/1a/40c2ed6f0d27b283c46ac58047f2f7335c24c07a8ee6b01c36af7a73a0af/jq-1.10.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:591d81677336551fd9cf3b5e23c1929ae3cd039a5c2d5fb8042870ed5372fd8c", size = 406595 }, - { url = "https://files.pythonhosted.org/packages/1d/b5/bb7ac9bf5cd636eea94b8b7ae66bccb30c0baeb1234b7cf60f1c8c9f061a/jq-1.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:f7f68716e8533294d2f5152e8659288091ea705778a00e066ed3b418ed724d81", size = 414867 }, - { url = "https://files.pythonhosted.org/packages/d8/62/55b0a9de733f38b77afb54782d2c55031e7de0922077e6ade563a6c450af/jq-1.10.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72658f31d5723e7b87eea929e81d5083fd4132636a9dcdbf56ba9ea0e30ecaa3", size = 415155 }, - { url = "https://files.pythonhosted.org/packages/cc/58/7fea03a5376f380aa85ada547e9c1fd5a9c14ba1cb037a66ac8df21977d5/jq-1.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:039e84e19306f94bf0d15291f6d358c4086c702de48e2309c3183fd866bf2785", size = 430258 }, - { url = "https://files.pythonhosted.org/packages/9b/6b/09a130d0e9fbae0f8c5013f5a1bf77a8d760380177aa701c3bf6773c51aa/jq-1.10.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e985ded6dc2105707cb04e98ca6acbe6c24614824ed0a2fae442d2b2bc78fbc4", size = 439087 }, - { url = "https://files.pythonhosted.org/packages/a6/bd/03f20025366149cd93eba483f874511461d2c6ad3a13cfd5b9de1c0bab00/jq-1.10.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:185d450fb45cd44ad5939ec5813f1ed0d057fa0cb12a1ba6a5fe0e49d8354958", size = 406997 }, - { url = "https://files.pythonhosted.org/packages/83/28/e2a57a342040f239b384a90dfb0ff2253d061411b07d816334862645404e/jq-1.10.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e6649eb4ce390342e07c95c2fa10fed043410408620296122f0ac48a7576f1f", size = 415060 }, - { url = "https://files.pythonhosted.org/packages/59/30/e62568fb245cd207cfd2d9c931a0dcc9cbbdfe171733b688dbbbc0575b14/jq-1.10.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:002d93e50fab9d92035dfd79fd134052692be649e1b3835662a053016d9f2ee7", size = 414979 }, - { url = "https://files.pythonhosted.org/packages/99/fd/d00bd8f4a58b34d7e646ba9e2c9b5f7d5386472a15ef0fa8d8e65df51dfb/jq-1.10.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1602f95ffaef7ee357b65f414b59d6284619bd3a0a588c15c3a1ae534811c1fb", size = 430400 }, - { url = "https://files.pythonhosted.org/packages/26/75/9d93d9ae98858b60c2351a33e1e87e873c0ade56dd3c4f909669ca9cbaff/jq-1.10.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b28cfd1eac69e1dc14518ed9c33e497041e9f9310e5f6259fa9d18fe342fb50", size = 439222 }, - { url = "https://files.pythonhosted.org/packages/ce/d6/977392c4ead380e9331baa1998f6fdf3d8b5d891d505ddc36f9b10998649/jq-1.10.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:061225bc6b45b399f4dfbece00f4fae78560c1d4e0f2af77203dde621c5f10be", size = 414349 }, - { url = "https://files.pythonhosted.org/packages/90/a1/80b6db61cd23d728ef0b6e77faa3286cc8abc64b30528c13a56e98acb115/jq-1.10.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dba7abfe55efe3f139247a30e1f13e94f33fddfea71245a18a817b619cb9fe9", size = 406150 }, - { url = "https://files.pythonhosted.org/packages/ad/0c/47473931f2a1609aa37978b6dcc6565a1669dd8ff90ad353ec8d5dc5ed3c/jq-1.10.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c6f45acaad61c1947bf2fa172a2ccb2e882231c3cfbfc1ea4a2c4f032122a546", size = 414736 }, - { url = "https://files.pythonhosted.org/packages/c0/eb/43b8ef1eea2ef02c0cc6e67ce665f07ac8d12d12f65f3c0d3ab73b8f304e/jq-1.10.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16c250cd0a708d45b9bb08fdf4cac415156274f7f3f8f026e75b5a330d2162dd", size = 414996 }, - { url = "https://files.pythonhosted.org/packages/50/9c/325f7a4026d6ebbe40fe216eb13f9847c205c25fbbdd904bc49f90fc7b0b/jq-1.10.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:593551afc5f305c7d0adc840587350cb49c4ecba6464f4fc965cae87758621a7", size = 429982 }, - { url = "https://files.pythonhosted.org/packages/3a/b2/915a9c4af214023e60f6d66387f536bedce945d8885feaf7ea30d46deeab/jq-1.10.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c550705145480b616b6bc114ab7f421c0d9a3041ad3dcb9424f992954823f7c2", size = 438909 }, -] - -[[package]] -name = "llm" -version = "0.16" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "click-default-group", marker = "python_full_version < '3.9'" }, - { name = "openai", version = "2.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pip", version = "25.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pyreadline3", marker = "python_full_version < '3.9' and sys_platform == 'win32'" }, - { name = "python-ulid", version = "1.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pyyaml", marker = "python_full_version < '3.9'" }, - { name = "setuptools", version = "75.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "sqlite-migrate", marker = "python_full_version < '3.9'" }, - { name = "sqlite-utils", version = "3.38", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7e/cc/57294e607f85c2d922c14f53078ae7545f7bec951e5514416ad88bd72a32/llm-0.16.tar.gz", hash = "sha256:6f8780308d021bb8df755e58e0188ab34af0961f64e303e808f928b456ccc51b", size = 36774 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/8e/a450b23155621c28ae46716a6398afcd9e23fa5a34401d3ca8e4560f5ee4/llm-0.16-py3-none-any.whl", hash = "sha256:b9fe4f43b0b7da4b2f53d9c051e6abf3cd5db7b14fee6747655b9242f0aee22e", size = 38335 }, -] - -[[package]] -name = "llm" -version = "0.27.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "click-default-group", marker = "python_full_version >= '3.9'" }, - { name = "condense-json", marker = "python_full_version >= '3.9'" }, - { name = "openai", version = "2.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pip", version = "25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "puremagic", marker = "python_full_version >= '3.9'" }, - { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pyreadline3", marker = "python_full_version >= '3.9' and sys_platform == 'win32'" }, - { name = "python-ulid", version = "3.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pyyaml", marker = "python_full_version >= '3.9'" }, - { name = "setuptools", version = "80.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "sqlite-migrate", marker = "python_full_version >= '3.9'" }, - { name = "sqlite-utils", version = "3.38", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "sqlite-utils", version = "3.39", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/05/7f/f2fe103b8fa6c5a96ba117fef46af15c766d4c28640893c2c7feb79c0df3/llm-0.27.1.tar.gz", hash = "sha256:02b0b393e31cf0e0ee1f2a6006c451c74ec18c7ec3973218de56e76fd72baa80", size = 85109 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/67/47585c961ff2299749e519891681025c2685b75801e3f784ee232853c5b0/llm-0.27.1-py3-none-any.whl", hash = "sha256:a884a575062fbea8c2b129708a80e146fa9682bd1c444d8d7b028196107de727", size = 82500 }, -] - -[[package]] -name = "markdown-it-py" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528 }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "mdurl", marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, -] - -[[package]] -name = "multidict" -version = "6.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/68/259dee7fd14cf56a17c554125e534f6274c2860159692a414d0b402b9a6d/multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60", size = 48628 }, - { url = "https://files.pythonhosted.org/packages/50/79/53ba256069fe5386a4a9e80d4e12857ced9de295baf3e20c68cdda746e04/multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1", size = 29327 }, - { url = "https://files.pythonhosted.org/packages/ff/10/71f1379b05b196dae749b5ac062e87273e3f11634f447ebac12a571d90ae/multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53", size = 29689 }, - { url = "https://files.pythonhosted.org/packages/71/45/70bac4f87438ded36ad4793793c0095de6572d433d98575a5752629ef549/multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5", size = 126639 }, - { url = "https://files.pythonhosted.org/packages/80/cf/17f35b3b9509b4959303c05379c4bfb0d7dd05c3306039fc79cf035bbac0/multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581", size = 134315 }, - { url = "https://files.pythonhosted.org/packages/ef/1f/652d70ab5effb33c031510a3503d4d6efc5ec93153562f1ee0acdc895a57/multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56", size = 129471 }, - { url = "https://files.pythonhosted.org/packages/a6/64/2dd6c4c681688c0165dea3975a6a4eab4944ea30f35000f8b8af1df3148c/multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429", size = 124585 }, - { url = "https://files.pythonhosted.org/packages/87/56/e6ee5459894c7e554b57ba88f7257dc3c3d2d379cb15baaa1e265b8c6165/multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748", size = 116957 }, - { url = "https://files.pythonhosted.org/packages/36/9e/616ce5e8d375c24b84f14fc263c7ef1d8d5e8ef529dbc0f1df8ce71bb5b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db", size = 128609 }, - { url = "https://files.pythonhosted.org/packages/8c/4f/4783e48a38495d000f2124020dc96bacc806a4340345211b1ab6175a6cb4/multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056", size = 123016 }, - { url = "https://files.pythonhosted.org/packages/3e/b3/4950551ab8fc39862ba5e9907dc821f896aa829b4524b4deefd3e12945ab/multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76", size = 133542 }, - { url = "https://files.pythonhosted.org/packages/96/4d/f0ce6ac9914168a2a71df117935bb1f1781916acdecbb43285e225b484b8/multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160", size = 130163 }, - { url = "https://files.pythonhosted.org/packages/be/72/17c9f67e7542a49dd252c5ae50248607dfb780bcc03035907dafefb067e3/multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7", size = 126832 }, - { url = "https://files.pythonhosted.org/packages/71/9f/72d719e248cbd755c8736c6d14780533a1606ffb3fbb0fbd77da9f0372da/multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0", size = 26402 }, - { url = "https://files.pythonhosted.org/packages/04/5a/d88cd5d00a184e1ddffc82aa2e6e915164a6d2641ed3606e766b5d2f275a/multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d", size = 28800 }, - { url = "https://files.pythonhosted.org/packages/93/13/df3505a46d0cd08428e4c8169a196131d1b0c4b515c3649829258843dde6/multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6", size = 48570 }, - { url = "https://files.pythonhosted.org/packages/f0/e1/a215908bfae1343cdb72f805366592bdd60487b4232d039c437fe8f5013d/multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156", size = 29316 }, - { url = "https://files.pythonhosted.org/packages/70/0f/6dc70ddf5d442702ed74f298d69977f904960b82368532c88e854b79f72b/multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb", size = 29640 }, - { url = "https://files.pythonhosted.org/packages/d8/6d/9c87b73a13d1cdea30b321ef4b3824449866bd7f7127eceed066ccb9b9ff/multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b", size = 131067 }, - { url = "https://files.pythonhosted.org/packages/cc/1e/1b34154fef373371fd6c65125b3d42ff5f56c7ccc6bfff91b9b3c60ae9e0/multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72", size = 138507 }, - { url = "https://files.pythonhosted.org/packages/fb/e0/0bc6b2bac6e461822b5f575eae85da6aae76d0e2a79b6665d6206b8e2e48/multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304", size = 133905 }, - { url = "https://files.pythonhosted.org/packages/ba/af/73d13b918071ff9b2205fcf773d316e0f8fefb4ec65354bbcf0b10908cc6/multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351", size = 129004 }, - { url = "https://files.pythonhosted.org/packages/74/21/23960627b00ed39643302d81bcda44c9444ebcdc04ee5bedd0757513f259/multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb", size = 121308 }, - { url = "https://files.pythonhosted.org/packages/8b/5c/cf282263ffce4a596ed0bb2aa1a1dddfe1996d6a62d08842a8d4b33dca13/multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3", size = 132608 }, - { url = "https://files.pythonhosted.org/packages/d7/3e/97e778c041c72063f42b290888daff008d3ab1427f5b09b714f5a8eff294/multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399", size = 127029 }, - { url = "https://files.pythonhosted.org/packages/47/ac/3efb7bfe2f3aefcf8d103e9a7162572f01936155ab2f7ebcc7c255a23212/multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423", size = 137594 }, - { url = "https://files.pythonhosted.org/packages/42/9b/6c6e9e8dc4f915fc90a9b7798c44a30773dea2995fdcb619870e705afe2b/multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3", size = 134556 }, - { url = "https://files.pythonhosted.org/packages/1d/10/8e881743b26aaf718379a14ac58572a240e8293a1c9d68e1418fb11c0f90/multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753", size = 130993 }, - { url = "https://files.pythonhosted.org/packages/45/84/3eb91b4b557442802d058a7579e864b329968c8d0ea57d907e7023c677f2/multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80", size = 26405 }, - { url = "https://files.pythonhosted.org/packages/9f/0b/ad879847ecbf6d27e90a6eabb7eff6b62c129eefe617ea45eae7c1f0aead/multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926", size = 28795 }, - { url = "https://files.pythonhosted.org/packages/fd/16/92057c74ba3b96d5e211b553895cd6dc7cc4d1e43d9ab8fafc727681ef71/multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa", size = 48713 }, - { url = "https://files.pythonhosted.org/packages/94/3d/37d1b8893ae79716179540b89fc6a0ee56b4a65fcc0d63535c6f5d96f217/multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436", size = 29516 }, - { url = "https://files.pythonhosted.org/packages/a2/12/adb6b3200c363062f805275b4c1e656be2b3681aada66c80129932ff0bae/multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761", size = 29557 }, - { url = "https://files.pythonhosted.org/packages/47/e9/604bb05e6e5bce1e6a5cf80a474e0f072e80d8ac105f1b994a53e0b28c42/multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e", size = 130170 }, - { url = "https://files.pythonhosted.org/packages/7e/13/9efa50801785eccbf7086b3c83b71a4fb501a4d43549c2f2f80b8787d69f/multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef", size = 134836 }, - { url = "https://files.pythonhosted.org/packages/bf/0f/93808b765192780d117814a6dfcc2e75de6dcc610009ad408b8814dca3ba/multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95", size = 133475 }, - { url = "https://files.pythonhosted.org/packages/d3/c8/529101d7176fe7dfe1d99604e48d69c5dfdcadb4f06561f465c8ef12b4df/multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925", size = 131049 }, - { url = "https://files.pythonhosted.org/packages/ca/0c/fc85b439014d5a58063e19c3a158a889deec399d47b5269a0f3b6a2e28bc/multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966", size = 120370 }, - { url = "https://files.pythonhosted.org/packages/db/46/d4416eb20176492d2258fbd47b4abe729ff3b6e9c829ea4236f93c865089/multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305", size = 125178 }, - { url = "https://files.pythonhosted.org/packages/5b/46/73697ad7ec521df7de5531a32780bbfd908ded0643cbe457f981a701457c/multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2", size = 119567 }, - { url = "https://files.pythonhosted.org/packages/cd/ed/51f060e2cb0e7635329fa6ff930aa5cffa17f4c7f5c6c3ddc3500708e2f2/multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2", size = 129822 }, - { url = "https://files.pythonhosted.org/packages/df/9e/ee7d1954b1331da3eddea0c4e08d9142da5f14b1321c7301f5014f49d492/multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6", size = 128656 }, - { url = "https://files.pythonhosted.org/packages/77/00/8538f11e3356b5d95fa4b024aa566cde7a38aa7a5f08f4912b32a037c5dc/multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3", size = 125360 }, - { url = "https://files.pythonhosted.org/packages/be/05/5d334c1f2462d43fec2363cd00b1c44c93a78c3925d952e9a71caf662e96/multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133", size = 26382 }, - { url = "https://files.pythonhosted.org/packages/a3/bf/f332a13486b1ed0496d624bcc7e8357bb8053823e8cd4b9a18edc1d97e73/multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1", size = 28529 }, - { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 }, - { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 }, - { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 }, - { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 }, - { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 }, - { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 }, - { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 }, - { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 }, - { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 }, - { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 }, - { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 }, - { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 }, - { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 }, - { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 }, - { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 }, - { url = "https://files.pythonhosted.org/packages/3e/6a/af41f3aaf5f00fd86cc7d470a2f5b25299b0c84691163b8757f4a1a205f2/multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392", size = 48597 }, - { url = "https://files.pythonhosted.org/packages/d9/d6/3d4082760ed11b05734f8bf32a0615b99e7d9d2b3730ad698a4d7377c00a/multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a", size = 29338 }, - { url = "https://files.pythonhosted.org/packages/9d/7f/5d1ce7f47d44393d429922910afbe88fcd29ee3069babbb47507a4c3a7ea/multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2", size = 29562 }, - { url = "https://files.pythonhosted.org/packages/ce/ec/c425257671af9308a9b626e2e21f7f43841616e4551de94eb3c92aca75b2/multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc", size = 130980 }, - { url = "https://files.pythonhosted.org/packages/d8/d7/d4220ad2633a89b314593e9b85b5bc9287a7c563c7f9108a4a68d9da5374/multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478", size = 136694 }, - { url = "https://files.pythonhosted.org/packages/a1/2a/13e554db5830c8d40185a2e22aa8325516a5de9634c3fb2caf3886a829b3/multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4", size = 131616 }, - { url = "https://files.pythonhosted.org/packages/2e/a9/83692e37d8152f104333132105b67100aabfb2e96a87f6bed67f566035a7/multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d", size = 129664 }, - { url = "https://files.pythonhosted.org/packages/cc/1c/1718cd518fb9da7e8890d9d1611c1af0ea5e60f68ff415d026e38401ed36/multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6", size = 121855 }, - { url = "https://files.pythonhosted.org/packages/2b/92/f6ed67514b0e3894198f0eb42dcde22f0851ea35f4561a1e4acf36c7b1be/multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2", size = 127928 }, - { url = "https://files.pythonhosted.org/packages/f7/30/c66954115a4dc4dc3c84e02c8ae11bb35a43d79ef93122c3c3a40c4d459b/multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd", size = 122793 }, - { url = "https://files.pythonhosted.org/packages/62/c9/d386d01b43871e8e1631eb7b3695f6af071b7ae1ab716caf371100f0eb24/multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6", size = 132762 }, - { url = "https://files.pythonhosted.org/packages/69/ff/f70cb0a2f7a358acf48e32139ce3a150ff18c961ee9c714cc8c0dc7e3584/multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492", size = 127872 }, - { url = "https://files.pythonhosted.org/packages/89/5b/abea7db3ba4cd07752a9b560f9275a11787cd13f86849b5d99c1ceea921d/multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd", size = 126161 }, - { url = "https://files.pythonhosted.org/packages/22/03/acc77a4667cca4462ee974fc39990803e58fa573d5a923d6e82b7ef6da7e/multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167", size = 26338 }, - { url = "https://files.pythonhosted.org/packages/90/bf/3d0c1cc9c8163abc24625fae89c0ade1ede9bccb6eceb79edf8cff3cca46/multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef", size = 28736 }, - { url = "https://files.pythonhosted.org/packages/e7/c9/9e153a6572b38ac5ff4434113af38acf8d5e9957897cdb1f513b3d6614ed/multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c", size = 48550 }, - { url = "https://files.pythonhosted.org/packages/76/f5/79565ddb629eba6c7f704f09a09df085c8dc04643b12506f10f718cee37a/multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1", size = 29298 }, - { url = "https://files.pythonhosted.org/packages/60/1b/9851878b704bc98e641a3e0bce49382ae9e05743dac6d97748feb5b7baba/multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c", size = 29641 }, - { url = "https://files.pythonhosted.org/packages/89/87/d451d45aab9e422cb0fb2f7720c31a4c1d3012c740483c37f642eba568fb/multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c", size = 126202 }, - { url = "https://files.pythonhosted.org/packages/fa/b4/27cbe9f3e2e469359887653f2e45470272eef7295139916cc21107c6b48c/multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f", size = 133925 }, - { url = "https://files.pythonhosted.org/packages/4d/a3/afc841899face8adfd004235ce759a37619f6ec99eafd959650c5ce4df57/multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875", size = 129039 }, - { url = "https://files.pythonhosted.org/packages/5e/41/0d0fb18c1ad574f807196f5f3d99164edf9de3e169a58c6dc2d6ed5742b9/multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255", size = 124072 }, - { url = "https://files.pythonhosted.org/packages/00/22/defd7a2e71a44e6e5b9a5428f972e5b572e7fe28e404dfa6519bbf057c93/multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30", size = 116532 }, - { url = "https://files.pythonhosted.org/packages/91/25/f7545102def0b1d456ab6449388eed2dfd822debba1d65af60194904a23a/multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057", size = 128173 }, - { url = "https://files.pythonhosted.org/packages/45/79/3dbe8d35fc99f5ea610813a72ab55f426cb9cf482f860fa8496e5409be11/multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657", size = 122654 }, - { url = "https://files.pythonhosted.org/packages/97/cb/209e735eeab96e1b160825b5d0b36c56d3862abff828fc43999bb957dcad/multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28", size = 133197 }, - { url = "https://files.pythonhosted.org/packages/e4/3a/a13808a7ada62808afccea67837a79d00ad6581440015ef00f726d064c2d/multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972", size = 129754 }, - { url = "https://files.pythonhosted.org/packages/77/dd/8540e139eafb240079242da8f8ffdf9d3f4b4ad1aac5a786cd4050923783/multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43", size = 126402 }, - { url = "https://files.pythonhosted.org/packages/86/99/e82e1a275d8b1ea16d3a251474262258dbbe41c05cce0c01bceda1fc8ea5/multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada", size = 26421 }, - { url = "https://files.pythonhosted.org/packages/86/1c/9fa630272355af7e4446a2c7550c259f11ee422ab2d30ff90a0a71cf3d9e/multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a", size = 28791 }, - { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, -] - -[[package]] -name = "multidict" -version = "6.7.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9' and python_full_version < '3.11'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153 }, - { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993 }, - { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607 }, - { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847 }, - { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616 }, - { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333 }, - { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239 }, - { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618 }, - { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655 }, - { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245 }, - { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523 }, - { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129 }, - { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999 }, - { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711 }, - { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504 }, - { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422 }, - { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050 }, - { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153 }, - { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604 }, - { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715 }, - { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332 }, - { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212 }, - { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671 }, - { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491 }, - { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322 }, - { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694 }, - { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715 }, - { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189 }, - { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845 }, - { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374 }, - { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345 }, - { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940 }, - { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229 }, - { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308 }, - { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037 }, - { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023 }, - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877 }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467 }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834 }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545 }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305 }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363 }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375 }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346 }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107 }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592 }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024 }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484 }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579 }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654 }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511 }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895 }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073 }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226 }, - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135 }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117 }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472 }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342 }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082 }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704 }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355 }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259 }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903 }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365 }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062 }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683 }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254 }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967 }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085 }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713 }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915 }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077 }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114 }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442 }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885 }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588 }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966 }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618 }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539 }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345 }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934 }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243 }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878 }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452 }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312 }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935 }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385 }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777 }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104 }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503 }, - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128 }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410 }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205 }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084 }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667 }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590 }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112 }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194 }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510 }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395 }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520 }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479 }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903 }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333 }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411 }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940 }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087 }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368 }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326 }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065 }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475 }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324 }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877 }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824 }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558 }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339 }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895 }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862 }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376 }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272 }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774 }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731 }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193 }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023 }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507 }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804 }, - { url = "https://files.pythonhosted.org/packages/90/d7/4cf84257902265c4250769ac49f4eaab81c182ee9aff8bf59d2714dbb174/multidict-6.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:363eb68a0a59bd2303216d2346e6c441ba10d36d1f9969fcb6f1ba700de7bb5c", size = 77073 }, - { url = "https://files.pythonhosted.org/packages/6d/51/194e999630a656e76c2965a1590d12faa5cd528170f2abaa04423e09fe8d/multidict-6.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d874eb056410ca05fed180b6642e680373688efafc7f077b2a2f61811e873a40", size = 44928 }, - { url = "https://files.pythonhosted.org/packages/e5/6b/2a195373c33068c9158e0941d0b46cfcc9c1d894ca2eb137d1128081dff0/multidict-6.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b55d5497b51afdfde55925e04a022f1de14d4f4f25cdfd4f5d9b0aa96166851", size = 44581 }, - { url = "https://files.pythonhosted.org/packages/69/7b/7f4f2e644b6978bf011a5fd9a5ebb7c21de3f38523b1f7897d36a1ac1311/multidict-6.7.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f8e5c0031b90ca9ce555e2e8fd5c3b02a25f14989cbc310701823832c99eb687", size = 239901 }, - { url = "https://files.pythonhosted.org/packages/3c/b5/952c72786710a031aa204a9adf7db66d7f97a2c6573889d58b9e60fe6702/multidict-6.7.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cf41880c991716f3c7cec48e2f19ae4045fc9db5fc9cff27347ada24d710bb5", size = 240534 }, - { url = "https://files.pythonhosted.org/packages/f3/ef/109fe1f2471e4c458c74242c7e4a833f2d9fc8a6813cd7ee345b0bad18f9/multidict-6.7.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cfc12a8630a29d601f48d47787bd7eb730e475e83edb5d6c5084317463373eb", size = 219545 }, - { url = "https://files.pythonhosted.org/packages/42/bd/327d91288114967f9fe90dc53de70aa3fec1b9073e46aa32c4828f771a87/multidict-6.7.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3996b50c3237c4aec17459217c1e7bbdead9a22a0fcd3c365564fbd16439dde6", size = 251187 }, - { url = "https://files.pythonhosted.org/packages/f4/13/a8b078ebbaceb7819fd28cd004413c33b98f1b70d542a62e6a00b74fb09f/multidict-6.7.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7f5170993a0dd3ab871c74f45c0a21a4e2c37a2f2b01b5f722a2ad9c6650469e", size = 249379 }, - { url = "https://files.pythonhosted.org/packages/e3/6d/ab12e1246be4d65d1f55de1e6f6aaa9b8120eddcfdd1d290439c7833d5ce/multidict-6.7.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ec81878ddf0e98817def1e77d4f50dae5ef5b0e4fe796fae3bd674304172416e", size = 239241 }, - { url = "https://files.pythonhosted.org/packages/bb/d7/079a93625208c173b8fa756396814397c0fd9fee61ef87b75a748820b86e/multidict-6.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9281bf5b34f59afbc6b1e477a372e9526b66ca446f4bf62592839c195a718b32", size = 237418 }, - { url = "https://files.pythonhosted.org/packages/c9/29/03777c2212274aa9440918d604dc9d6af0e6b4558c611c32c3dcf1a13870/multidict-6.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:68af405971779d8b37198726f2b6fe3955db846fee42db7a4286fc542203934c", size = 232987 }, - { url = "https://files.pythonhosted.org/packages/d9/00/11188b68d85a84e8050ee34724d6ded19ad03975caebe0c8dcb2829b37bf/multidict-6.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3ba3ef510467abb0667421a286dc906e30eb08569365f5cdb131d7aff7c2dd84", size = 240985 }, - { url = "https://files.pythonhosted.org/packages/df/0c/12eef6aeda21859c6cdf7d75bd5516d83be3efe3d8cc45fd1a3037f5b9dc/multidict-6.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b61189b29081a20c7e4e0b49b44d5d44bb0dc92be3c6d06a11cc043f81bf9329", size = 246855 }, - { url = "https://files.pythonhosted.org/packages/69/f6/076120fd8bb3975f09228e288e08bff6b9f1bfd5166397c7ba284f622ab2/multidict-6.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fb287618b9c7aa3bf8d825f02d9201b2f13078a5ed3b293c8f4d953917d84d5e", size = 241804 }, - { url = "https://files.pythonhosted.org/packages/5f/51/41bb950c81437b88a93e6ddfca1d8763569ae861e638442838c4375f7497/multidict-6.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:521f33e377ff64b96c4c556b81c55d0cfffb96a11c194fd0c3f1e56f3d8dd5a4", size = 235321 }, - { url = "https://files.pythonhosted.org/packages/5a/cf/5bbd31f055199d56c1f6b04bbadad3ccb24e6d5d4db75db774fc6d6674b8/multidict-6.7.0-cp39-cp39-win32.whl", hash = "sha256:ce8fdc2dca699f8dbf055a61d73eaa10482569ad20ee3c36ef9641f69afa8c91", size = 41435 }, - { url = "https://files.pythonhosted.org/packages/af/01/547ffe9c2faec91c26965c152f3fea6cff068b6037401f61d310cc861ff4/multidict-6.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:7e73299c99939f089dd9b2120a04a516b95cdf8c1cd2b18c53ebf0de80b1f18f", size = 46193 }, - { url = "https://files.pythonhosted.org/packages/27/77/cfa5461d1d2651d6fc24216c92b4a21d4e385a41c46e0d9f3b070675167b/multidict-6.7.0-cp39-cp39-win_arm64.whl", hash = "sha256:6bdce131e14b04fd34a809b6380dbfd826065c3e2fe8a50dbae659fa0c390546", size = 43118 }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317 }, -] - -[[package]] -name = "openai" -version = "2.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "anyio", version = "4.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "distro", marker = "python_full_version < '3.9'" }, - { name = "httpx", marker = "python_full_version < '3.9'" }, - { name = "jiter", version = "0.9.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pydantic", version = "2.10.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "sniffio", marker = "python_full_version < '3.9'" }, - { name = "tqdm", marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b8/b1/8201e321a7d64a25c6f5a560320272d8be70547add40311fceb916518632/openai-2.2.0.tar.gz", hash = "sha256:bc49d077a8bf0e370eec4d038bc05e232c20855a19df0b58e5b3e5a8da7d33e0", size = 588512 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/92/6aeef1836e66dfec7f7f160a4f06d7041be7f6ccfc47a2f0f5738b332245/openai-2.2.0-py3-none-any.whl", hash = "sha256:d222e63436e33f3134a3d7ce490dc2d2f146fa98036eb65cc225df3ce163916f", size = 998972 }, -] - -[[package]] -name = "openai" -version = "2.9.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "anyio", version = "4.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "distro", marker = "python_full_version >= '3.9'" }, - { name = "httpx", marker = "python_full_version >= '3.9'" }, - { name = "jiter", version = "0.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "pydantic", version = "2.12.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "sniffio", marker = "python_full_version >= '3.9'" }, - { name = "tqdm", marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/48/516290f38745cc1e72856f50e8afed4a7f9ac396a5a18f39e892ab89dfc2/openai-2.9.0.tar.gz", hash = "sha256:b52ec65727fc8f1eed2fbc86c8eac0998900c7ef63aa2eb5c24b69717c56fa5f", size = 608202 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/fd/ae2da789cd923dd033c99b8d544071a827c92046b150db01cfa5cea5b3fd/openai-2.9.0-py3-none-any.whl", hash = "sha256:0d168a490fbb45630ad508a6f3022013c155a68fd708069b6a1a01a5e8f0ffad", size = 1030836 }, -] - -[[package]] -name = "pip" -version = "25.0.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/70/53/b309b4a497b09655cb7e07088966881a57d082f48ac3cb54ea729fd2c6cf/pip-25.0.1.tar.gz", hash = "sha256:88f96547ea48b940a3a385494e181e29fb8637898f88d88737c5049780f196ea", size = 1950850 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/bc/b7db44f5f39f9d0494071bddae6880eb645970366d0a200022a1a93d57f5/pip-25.0.1-py3-none-any.whl", hash = "sha256:c46efd13b6aa8279f33f2864459c8ce587ea6a1a59ee20de055868d8f7688f7f", size = 1841526 }, -] - -[[package]] -name = "pip" -version = "25.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622 }, -] - -[[package]] -name = "pluggy" -version = "1.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, -] - -[[package]] -name = "propcache" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/08/1963dfb932b8d74d5b09098507b37e9b96c835ba89ab8aad35aa330f4ff3/propcache-0.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5869b8fd70b81835a6f187c5fdbe67917a04d7e52b6e7cc4e5fe39d55c39d58", size = 80712 }, - { url = "https://files.pythonhosted.org/packages/e6/59/49072aba9bf8a8ed958e576182d46f038e595b17ff7408bc7e8807e721e1/propcache-0.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:952e0d9d07609d9c5be361f33b0d6d650cd2bae393aabb11d9b719364521984b", size = 46301 }, - { url = "https://files.pythonhosted.org/packages/33/a2/6b1978c2e0d80a678e2c483f45e5443c15fe5d32c483902e92a073314ef1/propcache-0.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:33ac8f098df0585c0b53009f039dfd913b38c1d2edafed0cedcc0c32a05aa110", size = 45581 }, - { url = "https://files.pythonhosted.org/packages/43/95/55acc9adff8f997c7572f23d41993042290dfb29e404cdadb07039a4386f/propcache-0.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e48e8875e6c13909c800fa344cd54cc4b2b0db1d5f911f840458a500fde2c2", size = 208659 }, - { url = "https://files.pythonhosted.org/packages/bd/2c/ef7371ff715e6cd19ea03fdd5637ecefbaa0752fee5b0f2fe8ea8407ee01/propcache-0.2.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:388f3217649d6d59292b722d940d4d2e1e6a7003259eb835724092a1cca0203a", size = 222613 }, - { url = "https://files.pythonhosted.org/packages/5e/1c/fef251f79fd4971a413fa4b1ae369ee07727b4cc2c71e2d90dfcde664fbb/propcache-0.2.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f571aea50ba5623c308aa146eb650eebf7dbe0fd8c5d946e28343cb3b5aad577", size = 221067 }, - { url = "https://files.pythonhosted.org/packages/8d/e7/22e76ae6fc5a1708bdce92bdb49de5ebe89a173db87e4ef597d6bbe9145a/propcache-0.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3dfafb44f7bb35c0c06eda6b2ab4bfd58f02729e7c4045e179f9a861b07c9850", size = 208920 }, - { url = "https://files.pythonhosted.org/packages/04/3e/f10aa562781bcd8a1e0b37683a23bef32bdbe501d9cc7e76969becaac30d/propcache-0.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3ebe9a75be7ab0b7da2464a77bb27febcb4fab46a34f9288f39d74833db7f61", size = 200050 }, - { url = "https://files.pythonhosted.org/packages/d0/98/8ac69f638358c5f2a0043809c917802f96f86026e86726b65006830f3dc6/propcache-0.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d2f0d0f976985f85dfb5f3d685697ef769faa6b71993b46b295cdbbd6be8cc37", size = 202346 }, - { url = "https://files.pythonhosted.org/packages/ee/78/4acfc5544a5075d8e660af4d4e468d60c418bba93203d1363848444511ad/propcache-0.2.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a3dc1a4b165283bd865e8f8cb5f0c64c05001e0718ed06250d8cac9bec115b48", size = 199750 }, - { url = "https://files.pythonhosted.org/packages/a2/8f/90ada38448ca2e9cf25adc2fe05d08358bda1b9446f54a606ea38f41798b/propcache-0.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e0f07b42d2a50c7dd2d8675d50f7343d998c64008f1da5fef888396b7f84630", size = 201279 }, - { url = "https://files.pythonhosted.org/packages/08/31/0e299f650f73903da851f50f576ef09bfffc8e1519e6a2f1e5ed2d19c591/propcache-0.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e63e3e1e0271f374ed489ff5ee73d4b6e7c60710e1f76af5f0e1a6117cd26394", size = 211035 }, - { url = "https://files.pythonhosted.org/packages/85/3e/e356cc6b09064bff1c06d0b2413593e7c925726f0139bc7acef8a21e87a8/propcache-0.2.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:56bb5c98f058a41bb58eead194b4db8c05b088c93d94d5161728515bd52b052b", size = 215565 }, - { url = "https://files.pythonhosted.org/packages/8b/54/4ef7236cd657e53098bd05aa59cbc3cbf7018fba37b40eaed112c3921e51/propcache-0.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7665f04d0c7f26ff8bb534e1c65068409bf4687aa2534faf7104d7182debb336", size = 207604 }, - { url = "https://files.pythonhosted.org/packages/1f/27/d01d7799c068443ee64002f0655d82fb067496897bf74b632e28ee6a32cf/propcache-0.2.0-cp310-cp310-win32.whl", hash = "sha256:7cf18abf9764746b9c8704774d8b06714bcb0a63641518a3a89c7f85cc02c2ad", size = 40526 }, - { url = "https://files.pythonhosted.org/packages/bb/44/6c2add5eeafb7f31ff0d25fbc005d930bea040a1364cf0f5768750ddf4d1/propcache-0.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:cfac69017ef97db2438efb854edf24f5a29fd09a536ff3a992b75990720cdc99", size = 44958 }, - { url = "https://files.pythonhosted.org/packages/e0/1c/71eec730e12aec6511e702ad0cd73c2872eccb7cad39de8ba3ba9de693ef/propcache-0.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:63f13bf09cc3336eb04a837490b8f332e0db41da66995c9fd1ba04552e516354", size = 80811 }, - { url = "https://files.pythonhosted.org/packages/89/c3/7e94009f9a4934c48a371632197406a8860b9f08e3f7f7d922ab69e57a41/propcache-0.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608cce1da6f2672a56b24a015b42db4ac612ee709f3d29f27a00c943d9e851de", size = 46365 }, - { url = "https://files.pythonhosted.org/packages/c0/1d/c700d16d1d6903aeab28372fe9999762f074b80b96a0ccc953175b858743/propcache-0.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:466c219deee4536fbc83c08d09115249db301550625c7fef1c5563a584c9bc87", size = 45602 }, - { url = "https://files.pythonhosted.org/packages/2e/5e/4a3e96380805bf742712e39a4534689f4cddf5fa2d3a93f22e9fd8001b23/propcache-0.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc2db02409338bf36590aa985a461b2c96fce91f8e7e0f14c50c5fcc4f229016", size = 236161 }, - { url = "https://files.pythonhosted.org/packages/a5/85/90132481183d1436dff6e29f4fa81b891afb6cb89a7306f32ac500a25932/propcache-0.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6ed8db0a556343d566a5c124ee483ae113acc9a557a807d439bcecc44e7dfbb", size = 244938 }, - { url = "https://files.pythonhosted.org/packages/4a/89/c893533cb45c79c970834274e2d0f6d64383ec740be631b6a0a1d2b4ddc0/propcache-0.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91997d9cb4a325b60d4e3f20967f8eb08dfcb32b22554d5ef78e6fd1dda743a2", size = 243576 }, - { url = "https://files.pythonhosted.org/packages/8c/56/98c2054c8526331a05f205bf45cbb2cda4e58e56df70e76d6a509e5d6ec6/propcache-0.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c7dde9e533c0a49d802b4f3f218fa9ad0a1ce21f2c2eb80d5216565202acab4", size = 236011 }, - { url = "https://files.pythonhosted.org/packages/2d/0c/8b8b9f8a6e1abd869c0fa79b907228e7abb966919047d294ef5df0d136cf/propcache-0.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffcad6c564fe6b9b8916c1aefbb37a362deebf9394bd2974e9d84232e3e08504", size = 224834 }, - { url = "https://files.pythonhosted.org/packages/18/bb/397d05a7298b7711b90e13108db697732325cafdcd8484c894885c1bf109/propcache-0.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:97a58a28bcf63284e8b4d7b460cbee1edaab24634e82059c7b8c09e65284f178", size = 224946 }, - { url = "https://files.pythonhosted.org/packages/25/19/4fc08dac19297ac58135c03770b42377be211622fd0147f015f78d47cd31/propcache-0.2.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:945db8ee295d3af9dbdbb698cce9bbc5c59b5c3fe328bbc4387f59a8a35f998d", size = 217280 }, - { url = "https://files.pythonhosted.org/packages/7e/76/c79276a43df2096ce2aba07ce47576832b1174c0c480fe6b04bd70120e59/propcache-0.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39e104da444a34830751715f45ef9fc537475ba21b7f1f5b0f4d71a3b60d7fe2", size = 220088 }, - { url = "https://files.pythonhosted.org/packages/c3/9a/8a8cf428a91b1336b883f09c8b884e1734c87f724d74b917129a24fe2093/propcache-0.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c5ecca8f9bab618340c8e848d340baf68bcd8ad90a8ecd7a4524a81c1764b3db", size = 233008 }, - { url = "https://files.pythonhosted.org/packages/25/7b/768a8969abd447d5f0f3333df85c6a5d94982a1bc9a89c53c154bf7a8b11/propcache-0.2.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c436130cc779806bdf5d5fae0d848713105472b8566b75ff70048c47d3961c5b", size = 237719 }, - { url = "https://files.pythonhosted.org/packages/ed/0d/e5d68ccc7976ef8b57d80613ac07bbaf0614d43f4750cf953f0168ef114f/propcache-0.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:191db28dc6dcd29d1a3e063c3be0b40688ed76434622c53a284e5427565bbd9b", size = 227729 }, - { url = "https://files.pythonhosted.org/packages/05/64/17eb2796e2d1c3d0c431dc5f40078d7282f4645af0bb4da9097fbb628c6c/propcache-0.2.0-cp311-cp311-win32.whl", hash = "sha256:5f2564ec89058ee7c7989a7b719115bdfe2a2fb8e7a4543b8d1c0cc4cf6478c1", size = 40473 }, - { url = "https://files.pythonhosted.org/packages/83/c5/e89fc428ccdc897ade08cd7605f174c69390147526627a7650fb883e0cd0/propcache-0.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e2e54267980349b723cff366d1e29b138b9a60fa376664a157a342689553f71", size = 44921 }, - { url = "https://files.pythonhosted.org/packages/7c/46/a41ca1097769fc548fc9216ec4c1471b772cc39720eb47ed7e38ef0006a9/propcache-0.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ee7606193fb267be4b2e3b32714f2d58cad27217638db98a60f9efb5efeccc2", size = 80800 }, - { url = "https://files.pythonhosted.org/packages/75/4f/93df46aab9cc473498ff56be39b5f6ee1e33529223d7a4d8c0a6101a9ba2/propcache-0.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ee8fc02ca52e24bcb77b234f22afc03288e1dafbb1f88fe24db308910c4ac7", size = 46443 }, - { url = "https://files.pythonhosted.org/packages/0b/17/308acc6aee65d0f9a8375e36c4807ac6605d1f38074b1581bd4042b9fb37/propcache-0.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2e900bad2a8456d00a113cad8c13343f3b1f327534e3589acc2219729237a2e8", size = 45676 }, - { url = "https://files.pythonhosted.org/packages/65/44/626599d2854d6c1d4530b9a05e7ff2ee22b790358334b475ed7c89f7d625/propcache-0.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f52a68c21363c45297aca15561812d542f8fc683c85201df0bebe209e349f793", size = 246191 }, - { url = "https://files.pythonhosted.org/packages/f2/df/5d996d7cb18df076debae7d76ac3da085c0575a9f2be6b1f707fe227b54c/propcache-0.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e41d67757ff4fbc8ef2af99b338bfb955010444b92929e9e55a6d4dcc3c4f09", size = 251791 }, - { url = "https://files.pythonhosted.org/packages/2e/6d/9f91e5dde8b1f662f6dd4dff36098ed22a1ef4e08e1316f05f4758f1576c/propcache-0.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a64e32f8bd94c105cc27f42d3b658902b5bcc947ece3c8fe7bc1b05982f60e89", size = 253434 }, - { url = "https://files.pythonhosted.org/packages/3c/e9/1b54b7e26f50b3e0497cd13d3483d781d284452c2c50dd2a615a92a087a3/propcache-0.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55346705687dbd7ef0d77883ab4f6fabc48232f587925bdaf95219bae072491e", size = 248150 }, - { url = "https://files.pythonhosted.org/packages/a7/ef/a35bf191c8038fe3ce9a414b907371c81d102384eda5dbafe6f4dce0cf9b/propcache-0.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:00181262b17e517df2cd85656fcd6b4e70946fe62cd625b9d74ac9977b64d8d9", size = 233568 }, - { url = "https://files.pythonhosted.org/packages/97/d9/d00bb9277a9165a5e6d60f2142cd1a38a750045c9c12e47ae087f686d781/propcache-0.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6994984550eaf25dd7fc7bd1b700ff45c894149341725bb4edc67f0ffa94efa4", size = 229874 }, - { url = "https://files.pythonhosted.org/packages/8e/78/c123cf22469bdc4b18efb78893e69c70a8b16de88e6160b69ca6bdd88b5d/propcache-0.2.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:56295eb1e5f3aecd516d91b00cfd8bf3a13991de5a479df9e27dd569ea23959c", size = 225857 }, - { url = "https://files.pythonhosted.org/packages/31/1b/fd6b2f1f36d028820d35475be78859d8c89c8f091ad30e377ac49fd66359/propcache-0.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:439e76255daa0f8151d3cb325f6dd4a3e93043e6403e6491813bcaaaa8733887", size = 227604 }, - { url = "https://files.pythonhosted.org/packages/99/36/b07be976edf77a07233ba712e53262937625af02154353171716894a86a6/propcache-0.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f6475a1b2ecb310c98c28d271a30df74f9dd436ee46d09236a6b750a7599ce57", size = 238430 }, - { url = "https://files.pythonhosted.org/packages/0d/64/5822f496c9010e3966e934a011ac08cac8734561842bc7c1f65586e0683c/propcache-0.2.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3444cdba6628accf384e349014084b1cacd866fbb88433cd9d279d90a54e0b23", size = 244814 }, - { url = "https://files.pythonhosted.org/packages/fd/bd/8657918a35d50b18a9e4d78a5df7b6c82a637a311ab20851eef4326305c1/propcache-0.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a9d9b4d0a9b38d1c391bb4ad24aa65f306c6f01b512e10a8a34a2dc5675d348", size = 235922 }, - { url = "https://files.pythonhosted.org/packages/a8/6f/ec0095e1647b4727db945213a9f395b1103c442ef65e54c62e92a72a3f75/propcache-0.2.0-cp312-cp312-win32.whl", hash = "sha256:69d3a98eebae99a420d4b28756c8ce6ea5a29291baf2dc9ff9414b42676f61d5", size = 40177 }, - { url = "https://files.pythonhosted.org/packages/20/a2/bd0896fdc4f4c1db46d9bc361c8c79a9bf08ccc08ba054a98e38e7ba1557/propcache-0.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ad9c9b99b05f163109466638bd30ada1722abb01bbb85c739c50b6dc11f92dc3", size = 44446 }, - { url = "https://files.pythonhosted.org/packages/a8/a7/5f37b69197d4f558bfef5b4bceaff7c43cc9b51adf5bd75e9081d7ea80e4/propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", size = 78120 }, - { url = "https://files.pythonhosted.org/packages/c8/cd/48ab2b30a6b353ecb95a244915f85756d74f815862eb2ecc7a518d565b48/propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", size = 45127 }, - { url = "https://files.pythonhosted.org/packages/a5/ba/0a1ef94a3412aab057bd996ed5f0ac7458be5bf469e85c70fa9ceb43290b/propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", size = 44419 }, - { url = "https://files.pythonhosted.org/packages/b4/6c/ca70bee4f22fa99eacd04f4d2f1699be9d13538ccf22b3169a61c60a27fa/propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", size = 229611 }, - { url = "https://files.pythonhosted.org/packages/19/70/47b872a263e8511ca33718d96a10c17d3c853aefadeb86dc26e8421184b9/propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", size = 234005 }, - { url = "https://files.pythonhosted.org/packages/4f/be/3b0ab8c84a22e4a3224719099c1229ddfdd8a6a1558cf75cb55ee1e35c25/propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", size = 237270 }, - { url = "https://files.pythonhosted.org/packages/04/d8/f071bb000d4b8f851d312c3c75701e586b3f643fe14a2e3409b1b9ab3936/propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", size = 231877 }, - { url = "https://files.pythonhosted.org/packages/93/e7/57a035a1359e542bbb0a7df95aad6b9871ebee6dce2840cb157a415bd1f3/propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", size = 217848 }, - { url = "https://files.pythonhosted.org/packages/f0/93/d1dea40f112ec183398fb6c42fde340edd7bab202411c4aa1a8289f461b6/propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", size = 216987 }, - { url = "https://files.pythonhosted.org/packages/62/4c/877340871251145d3522c2b5d25c16a1690ad655fbab7bb9ece6b117e39f/propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", size = 212451 }, - { url = "https://files.pythonhosted.org/packages/7c/bb/a91b72efeeb42906ef58ccf0cdb87947b54d7475fee3c93425d732f16a61/propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", size = 212879 }, - { url = "https://files.pythonhosted.org/packages/9b/7f/ee7fea8faac57b3ec5d91ff47470c6c5d40d7f15d0b1fccac806348fa59e/propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", size = 222288 }, - { url = "https://files.pythonhosted.org/packages/ff/d7/acd67901c43d2e6b20a7a973d9d5fd543c6e277af29b1eb0e1f7bd7ca7d2/propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", size = 228257 }, - { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, - { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, - { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, - { url = "https://files.pythonhosted.org/packages/b4/94/2c3d64420fd58ed462e2b416386d48e72dec027cf7bb572066cf3866e939/propcache-0.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:53d1bd3f979ed529f0805dd35ddaca330f80a9a6d90bc0121d2ff398f8ed8861", size = 82315 }, - { url = "https://files.pythonhosted.org/packages/73/b7/9e2a17d9a126f2012b22ddc5d0979c28ca75104e24945214790c1d787015/propcache-0.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:83928404adf8fb3d26793665633ea79b7361efa0287dfbd372a7e74311d51ee6", size = 47188 }, - { url = "https://files.pythonhosted.org/packages/80/ef/18af27caaae5589c08bb5a461cfa136b83b7e7983be604f2140d91f92b97/propcache-0.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:77a86c261679ea5f3896ec060be9dc8e365788248cc1e049632a1be682442063", size = 46314 }, - { url = "https://files.pythonhosted.org/packages/fa/df/8dbd3e472baf73251c0fbb571a3f0a4e3a40c52a1c8c2a6c46ab08736ff9/propcache-0.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:218db2a3c297a3768c11a34812e63b3ac1c3234c3a086def9c0fee50d35add1f", size = 212874 }, - { url = "https://files.pythonhosted.org/packages/7c/57/5d4d783ac594bd56434679b8643673ae12de1ce758116fd8912a7f2313ec/propcache-0.2.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7735e82e3498c27bcb2d17cb65d62c14f1100b71723b68362872bca7d0913d90", size = 224578 }, - { url = "https://files.pythonhosted.org/packages/66/27/072be8ad434c9a3aa1b561f527984ea0ed4ac072fd18dfaaa2aa2d6e6a2b/propcache-0.2.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:20a617c776f520c3875cf4511e0d1db847a076d720714ae35ffe0df3e440be68", size = 222636 }, - { url = "https://files.pythonhosted.org/packages/c3/f1/69a30ff0928d07f50bdc6f0147fd9a08e80904fd3fdb711785e518de1021/propcache-0.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67b69535c870670c9f9b14a75d28baa32221d06f6b6fa6f77a0a13c5a7b0a5b9", size = 213573 }, - { url = "https://files.pythonhosted.org/packages/a8/2e/c16716ae113fe0a3219978df3665a6fea049d81d50bd28c4ae72a4c77567/propcache-0.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4569158070180c3855e9c0791c56be3ceeb192defa2cdf6a3f39e54319e56b89", size = 205438 }, - { url = "https://files.pythonhosted.org/packages/e1/df/80e2c5cd5ed56a7bfb1aa58cedb79617a152ae43de7c0a7e800944a6b2e2/propcache-0.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:db47514ffdbd91ccdc7e6f8407aac4ee94cc871b15b577c1c324236b013ddd04", size = 202352 }, - { url = "https://files.pythonhosted.org/packages/0f/4e/79f665fa04839f30ffb2903211c718b9660fbb938ac7a4df79525af5aeb3/propcache-0.2.0-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:2a60ad3e2553a74168d275a0ef35e8c0a965448ffbc3b300ab3a5bb9956c2162", size = 200476 }, - { url = "https://files.pythonhosted.org/packages/a9/39/b9ea7b011521dd7cfd2f89bb6b8b304f3c789ea6285445bc145bebc83094/propcache-0.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:662dd62358bdeaca0aee5761de8727cfd6861432e3bb828dc2a693aa0471a563", size = 201581 }, - { url = "https://files.pythonhosted.org/packages/e4/81/e8e96c97aa0b675a14e37b12ca9c9713b15cfacf0869e64bf3ab389fabf1/propcache-0.2.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:25a1f88b471b3bc911d18b935ecb7115dff3a192b6fef46f0bfaf71ff4f12418", size = 225628 }, - { url = "https://files.pythonhosted.org/packages/eb/99/15f998c502c214f6c7f51462937605d514a8943a9a6c1fa10f40d2710976/propcache-0.2.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:f60f0ac7005b9f5a6091009b09a419ace1610e163fa5deaba5ce3484341840e7", size = 229270 }, - { url = "https://files.pythonhosted.org/packages/ff/3a/a9f1a0c0e5b994b8f1a1c71bea56bb3e9eeec821cb4dd61e14051c4ba00b/propcache-0.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:74acd6e291f885678631b7ebc85d2d4aec458dd849b8c841b57ef04047833bed", size = 207771 }, - { url = "https://files.pythonhosted.org/packages/ff/3e/6103906a66d6713f32880cf6a5ba84a1406b4d66e1b9389bb9b8e1789f9e/propcache-0.2.0-cp38-cp38-win32.whl", hash = "sha256:d9b6ddac6408194e934002a69bcaadbc88c10b5f38fb9307779d1c629181815d", size = 41015 }, - { url = "https://files.pythonhosted.org/packages/37/23/a30214b4c1f2bea24cc1197ef48d67824fbc41d5cf5472b17c37fef6002c/propcache-0.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:676135dcf3262c9c5081cc8f19ad55c8a64e3f7282a21266d05544450bffc3a5", size = 45749 }, - { url = "https://files.pythonhosted.org/packages/38/05/797e6738c9f44ab5039e3ff329540c934eabbe8ad7e63c305c75844bc86f/propcache-0.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:25c8d773a62ce0451b020c7b29a35cfbc05de8b291163a7a0f3b7904f27253e6", size = 81903 }, - { url = "https://files.pythonhosted.org/packages/9f/84/8d5edb9a73e1a56b24dd8f2adb6aac223109ff0e8002313d52e5518258ba/propcache-0.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:375a12d7556d462dc64d70475a9ee5982465fbb3d2b364f16b86ba9135793638", size = 46960 }, - { url = "https://files.pythonhosted.org/packages/e7/77/388697bedda984af0d12d68e536b98129b167282da3401965c8450de510e/propcache-0.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1ec43d76b9677637a89d6ab86e1fef70d739217fefa208c65352ecf0282be957", size = 46133 }, - { url = "https://files.pythonhosted.org/packages/e2/dc/60d444610bc5b1d7a758534f58362b1bcee736a785473f8a39c91f05aad1/propcache-0.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45eec587dafd4b2d41ac189c2156461ebd0c1082d2fe7013571598abb8505d1", size = 211105 }, - { url = "https://files.pythonhosted.org/packages/bc/c6/40eb0dd1de6f8e84f454615ab61f68eb4a58f9d63d6f6eaf04300ac0cc17/propcache-0.2.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc092ba439d91df90aea38168e11f75c655880c12782facf5cf9c00f3d42b562", size = 226613 }, - { url = "https://files.pythonhosted.org/packages/de/b6/e078b5e9de58e20db12135eb6a206b4b43cb26c6b62ee0fe36ac40763a64/propcache-0.2.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fa1076244f54bb76e65e22cb6910365779d5c3d71d1f18b275f1dfc7b0d71b4d", size = 225587 }, - { url = "https://files.pythonhosted.org/packages/ce/4e/97059dd24494d1c93d1efb98bb24825e1930265b41858dd59c15cb37a975/propcache-0.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:682a7c79a2fbf40f5dbb1eb6bfe2cd865376deeac65acf9beb607505dced9e12", size = 211826 }, - { url = "https://files.pythonhosted.org/packages/fc/23/4dbf726602a989d2280fe130a9b9dd71faa8d3bb8cd23d3261ff3c23f692/propcache-0.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8e40876731f99b6f3c897b66b803c9e1c07a989b366c6b5b475fafd1f7ba3fb8", size = 203140 }, - { url = "https://files.pythonhosted.org/packages/5b/ce/f3bff82c885dbd9ae9e43f134d5b02516c3daa52d46f7a50e4f52ef9121f/propcache-0.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:363ea8cd3c5cb6679f1c2f5f1f9669587361c062e4899fce56758efa928728f8", size = 208841 }, - { url = "https://files.pythonhosted.org/packages/29/d7/19a4d3b4c7e95d08f216da97035d0b103d0c90411c6f739d47088d2da1f0/propcache-0.2.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:140fbf08ab3588b3468932974a9331aff43c0ab8a2ec2c608b6d7d1756dbb6cb", size = 203315 }, - { url = "https://files.pythonhosted.org/packages/db/87/5748212a18beb8d4ab46315c55ade8960d1e2cdc190764985b2d229dd3f4/propcache-0.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e70fac33e8b4ac63dfc4c956fd7d85a0b1139adcfc0d964ce288b7c527537fea", size = 204724 }, - { url = "https://files.pythonhosted.org/packages/84/2a/c3d2f989fc571a5bad0fabcd970669ccb08c8f9b07b037ecddbdab16a040/propcache-0.2.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:b33d7a286c0dc1a15f5fc864cc48ae92a846df287ceac2dd499926c3801054a6", size = 215514 }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4c44c133b08bc5f776afcb8f0833889c2636b8a83e07ea1d9096c1e401b0/propcache-0.2.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f6d5749fdd33d90e34c2efb174c7e236829147a2713334d708746e94c4bde40d", size = 220063 }, - { url = "https://files.pythonhosted.org/packages/2e/25/280d0a3bdaee68db74c0acd9a472e59e64b516735b59cffd3a326ff9058a/propcache-0.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:22aa8f2272d81d9317ff5756bb108021a056805ce63dd3630e27d042c8092798", size = 211620 }, - { url = "https://files.pythonhosted.org/packages/28/8c/266898981b7883c1563c35954f9ce9ced06019fdcc487a9520150c48dc91/propcache-0.2.0-cp39-cp39-win32.whl", hash = "sha256:73e4b40ea0eda421b115248d7e79b59214411109a5bc47d0d48e4c73e3b8fcf9", size = 41049 }, - { url = "https://files.pythonhosted.org/packages/af/53/a3e5b937f58e757a940716b88105ec4c211c42790c1ea17052b46dc16f16/propcache-0.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:9517d5e9e0731957468c29dbfd0f976736a0e55afaea843726e887f36fe017df", size = 45587 }, - { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, -] - -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/0e/934b541323035566a9af292dba85a195f7b78179114f2c6ebb24551118a9/propcache-0.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c2d1fa3201efaf55d730400d945b5b3ab6e672e100ba0f9a409d950ab25d7db", size = 79534 }, - { url = "https://files.pythonhosted.org/packages/a1/6b/db0d03d96726d995dc7171286c6ba9d8d14251f37433890f88368951a44e/propcache-0.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1eb2994229cc8ce7fe9b3db88f5465f5fd8651672840b2e426b88cdb1a30aac8", size = 45526 }, - { url = "https://files.pythonhosted.org/packages/e4/c3/82728404aea669e1600f304f2609cde9e665c18df5a11cdd57ed73c1dceb/propcache-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:66c1f011f45a3b33d7bcb22daed4b29c0c9e2224758b6be00686731e1b46f925", size = 47263 }, - { url = "https://files.pythonhosted.org/packages/df/1b/39313ddad2bf9187a1432654c38249bab4562ef535ef07f5eb6eb04d0b1b/propcache-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a52009f2adffe195d0b605c25ec929d26b36ef986ba85244891dee3b294df21", size = 201012 }, - { url = "https://files.pythonhosted.org/packages/5b/01/f1d0b57d136f294a142acf97f4ed58c8e5b974c21e543000968357115011/propcache-0.4.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5d4e2366a9c7b837555cf02fb9be2e3167d333aff716332ef1b7c3a142ec40c5", size = 209491 }, - { url = "https://files.pythonhosted.org/packages/a1/c8/038d909c61c5bb039070b3fb02ad5cccdb1dde0d714792e251cdb17c9c05/propcache-0.4.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9d2b6caef873b4f09e26ea7e33d65f42b944837563a47a94719cc3544319a0db", size = 215319 }, - { url = "https://files.pythonhosted.org/packages/08/57/8c87e93142b2c1fa2408e45695205a7ba05fb5db458c0bf5c06ba0e09ea6/propcache-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b16ec437a8c8a965ecf95739448dd938b5c7f56e67ea009f4300d8df05f32b7", size = 196856 }, - { url = "https://files.pythonhosted.org/packages/42/df/5615fec76aa561987a534759b3686008a288e73107faa49a8ae5795a9f7a/propcache-0.4.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:296f4c8ed03ca7476813fe666c9ea97869a8d7aec972618671b33a38a5182ef4", size = 193241 }, - { url = "https://files.pythonhosted.org/packages/d5/21/62949eb3a7a54afe8327011c90aca7e03547787a88fb8bd9726806482fea/propcache-0.4.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1f0978529a418ebd1f49dad413a2b68af33f85d5c5ca5c6ca2a3bed375a7ac60", size = 190552 }, - { url = "https://files.pythonhosted.org/packages/30/ee/ab4d727dd70806e5b4de96a798ae7ac6e4d42516f030ee60522474b6b332/propcache-0.4.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd138803047fb4c062b1c1dd95462f5209456bfab55c734458f15d11da288f8f", size = 200113 }, - { url = "https://files.pythonhosted.org/packages/8a/0b/38b46208e6711b016aa8966a3ac793eee0d05c7159d8342aa27fc0bc365e/propcache-0.4.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8c9b3cbe4584636d72ff556d9036e0c9317fa27b3ac1f0f558e7e84d1c9c5900", size = 200778 }, - { url = "https://files.pythonhosted.org/packages/cf/81/5abec54355ed344476bee711e9f04815d4b00a311ab0535599204eecc257/propcache-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f93243fdc5657247533273ac4f86ae106cc6445a0efacb9a1bfe982fcfefd90c", size = 193047 }, - { url = "https://files.pythonhosted.org/packages/ec/b6/1f237c04e32063cb034acd5f6ef34ef3a394f75502e72703545631ab1ef6/propcache-0.4.1-cp310-cp310-win32.whl", hash = "sha256:a0ee98db9c5f80785b266eb805016e36058ac72c51a064040f2bc43b61101cdb", size = 38093 }, - { url = "https://files.pythonhosted.org/packages/a6/67/354aac4e0603a15f76439caf0427781bcd6797f370377f75a642133bc954/propcache-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:1cdb7988c4e5ac7f6d175a28a9aa0c94cb6f2ebe52756a3c0cda98d2809a9e37", size = 41638 }, - { url = "https://files.pythonhosted.org/packages/e0/e1/74e55b9fd1a4c209ff1a9a824bf6c8b3d1fc5a1ac3eabe23462637466785/propcache-0.4.1-cp310-cp310-win_arm64.whl", hash = "sha256:d82ad62b19645419fe79dd63b3f9253e15b30e955c0170e5cebc350c1844e581", size = 38229 }, - { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208 }, - { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777 }, - { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647 }, - { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929 }, - { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778 }, - { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144 }, - { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030 }, - { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252 }, - { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064 }, - { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429 }, - { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727 }, - { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097 }, - { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084 }, - { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637 }, - { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064 }, - { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061 }, - { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037 }, - { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324 }, - { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505 }, - { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242 }, - { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474 }, - { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575 }, - { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736 }, - { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019 }, - { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376 }, - { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988 }, - { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615 }, - { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066 }, - { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655 }, - { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789 }, - { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750 }, - { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780 }, - { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308 }, - { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182 }, - { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215 }, - { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112 }, - { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442 }, - { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398 }, - { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920 }, - { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748 }, - { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877 }, - { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437 }, - { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586 }, - { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790 }, - { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158 }, - { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451 }, - { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374 }, - { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396 }, - { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950 }, - { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856 }, - { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420 }, - { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254 }, - { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205 }, - { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873 }, - { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739 }, - { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514 }, - { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781 }, - { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396 }, - { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897 }, - { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789 }, - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152 }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869 }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596 }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981 }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490 }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371 }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424 }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566 }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130 }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625 }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209 }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797 }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140 }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257 }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097 }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455 }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372 }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411 }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712 }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557 }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015 }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880 }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938 }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641 }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510 }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161 }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393 }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546 }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259 }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428 }, - { url = "https://files.pythonhosted.org/packages/9b/01/0ebaec9003f5d619a7475165961f8e3083cf8644d704b60395df3601632d/propcache-0.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3d233076ccf9e450c8b3bc6720af226b898ef5d051a2d145f7d765e6e9f9bcff", size = 80277 }, - { url = "https://files.pythonhosted.org/packages/34/58/04af97ac586b4ef6b9026c3fd36ee7798b737a832f5d3440a4280dcebd3a/propcache-0.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:357f5bb5c377a82e105e44bd3d52ba22b616f7b9773714bff93573988ef0a5fb", size = 45865 }, - { url = "https://files.pythonhosted.org/packages/7c/19/b65d98ae21384518b291d9939e24a8aeac4fdb5101b732576f8f7540e834/propcache-0.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cbc3b6dfc728105b2a57c06791eb07a94229202ea75c59db644d7d496b698cac", size = 47636 }, - { url = "https://files.pythonhosted.org/packages/b3/0f/317048c6d91c356c7154dca5af019e6effeb7ee15fa6a6db327cc19e12b4/propcache-0.4.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:182b51b421f0501952d938dc0b0eb45246a5b5153c50d42b495ad5fb7517c888", size = 201126 }, - { url = "https://files.pythonhosted.org/packages/71/69/0b2a7a5a6ee83292b4b997dbd80549d8ce7d40b6397c1646c0d9495f5a85/propcache-0.4.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b536b39c5199b96fc6245eb5fb796c497381d3942f169e44e8e392b29c9ebcc", size = 209837 }, - { url = "https://files.pythonhosted.org/packages/a5/92/c699ac495a6698df6e497fc2de27af4b6ace10d8e76528357ce153722e45/propcache-0.4.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db65d2af507bbfbdcedb254a11149f894169d90488dd3e7190f7cdcb2d6cd57a", size = 215578 }, - { url = "https://files.pythonhosted.org/packages/b3/ee/14de81c5eb02c0ee4f500b4e39c4e1bd0677c06e72379e6ab18923c773fc/propcache-0.4.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd2dbc472da1f772a4dae4fa24be938a6c544671a912e30529984dd80400cd88", size = 197187 }, - { url = "https://files.pythonhosted.org/packages/1d/94/48dce9aaa6d8dd5a0859bad75158ec522546d4ac23f8e2f05fac469477dd/propcache-0.4.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:daede9cd44e0f8bdd9e6cc9a607fc81feb80fae7a5fc6cecaff0e0bb32e42d00", size = 193478 }, - { url = "https://files.pythonhosted.org/packages/60/b5/0516b563e801e1ace212afde869a0596a0d7115eec0b12d296d75633fb29/propcache-0.4.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:71b749281b816793678ae7f3d0d84bd36e694953822eaad408d682efc5ca18e0", size = 190650 }, - { url = "https://files.pythonhosted.org/packages/24/89/e0f7d4a5978cd56f8cd67735f74052f257dc471ec901694e430f0d1572fe/propcache-0.4.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:0002004213ee1f36cfb3f9a42b5066100c44276b9b72b4e1504cddd3d692e86e", size = 200251 }, - { url = "https://files.pythonhosted.org/packages/06/7d/a1fac863d473876ed4406c914f2e14aa82d2f10dd207c9e16fc383cc5a24/propcache-0.4.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:fe49d0a85038f36ba9e3ffafa1103e61170b28e95b16622e11be0a0ea07c6781", size = 200919 }, - { url = "https://files.pythonhosted.org/packages/c3/4e/f86a256ff24944cf5743e4e6c6994e3526f6acfcfb55e21694c2424f758c/propcache-0.4.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:99d43339c83aaf4d32bda60928231848eee470c6bda8d02599cc4cebe872d183", size = 193211 }, - { url = "https://files.pythonhosted.org/packages/6e/3f/3fbad5f4356b068f1b047d300a6ff2c66614d7030f078cd50be3fec04228/propcache-0.4.1-cp39-cp39-win32.whl", hash = "sha256:a129e76735bc792794d5177069691c3217898b9f5cee2b2661471e52ffe13f19", size = 38314 }, - { url = "https://files.pythonhosted.org/packages/a4/45/d78d136c3a3d215677abb886785aae744da2c3005bcb99e58640c56529b1/propcache-0.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:948dab269721ae9a87fd16c514a0a2c2a1bdb23a9a61b969b0f9d9ee2968546f", size = 41912 }, - { url = "https://files.pythonhosted.org/packages/fc/2a/b0632941f25139f4e58450b307242951f7c2717a5704977c6d5323a800af/propcache-0.4.1-cp39-cp39-win_arm64.whl", hash = "sha256:5fd37c406dd6dc85aa743e214cef35dc54bbdd1419baac4f6ae5e5b1a2976938", size = 38450 }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305 }, -] - -[[package]] -name = "puremagic" -version = "1.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/7f/9998706bc516bdd664ccf929a1da6c6e5ee06e48f723ce45aae7cf3ff36e/puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9", size = 314785 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/ed/1e347d85d05b37a8b9a039ca832e5747e1e5248d0bd66042783ef48b4a37/puremagic-1.30-py3-none-any.whl", hash = "sha256:5eeeb2dd86f335b9cfe8e205346612197af3500c6872dffebf26929f56e9d3c1", size = 43304 }, -] - -[[package]] -name = "pyagfs" -version = "1.4.0" -source = { editable = "../agfs-sdk/python" } -dependencies = [ - { name = "requests", version = "2.32.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "requests", version = "2.32.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] - -[package.metadata] -requires-dist = [ - { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, - { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0.0" }, - { name = "requests", specifier = ">=2.31.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.0.270" }, -] -provides-extras = ["dev"] - -[[package]] -name = "pydantic" -version = "2.10.6" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "annotated-types", marker = "python_full_version < '3.9'" }, - { name = "pydantic-core", version = "2.27.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b7/ae/d5220c5c52b158b1de7ca89fc5edb72f304a70a4c540c84c8844bf4008de/pydantic-2.10.6.tar.gz", hash = "sha256:ca5daa827cce33de7a42be142548b0096bf05a7e7b365aebfa5f8eeec7128236", size = 761681 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/3c/8cc1cc84deffa6e25d2d0c688ebb80635dfdbf1dbea3e30c541c8cf4d860/pydantic-2.10.6-py3-none-any.whl", hash = "sha256:427d664bf0b8a2b34ff5dd0f5a18df00591adcee7198fbd71981054cef37b584", size = 431696 }, -] - -[[package]] -name = "pydantic" -version = "2.12.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.9'" }, - { name = "pydantic-core", version = "2.41.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580 }, -] - -[[package]] -name = "pydantic-core" -version = "2.27.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "typing-extensions", version = "4.13.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fc/01/f3e5ac5e7c25833db5eb555f7b7ab24cd6f8c322d3a3ad2d67a952dc0abc/pydantic_core-2.27.2.tar.gz", hash = "sha256:eb026e5a4c1fee05726072337ff51d1efb6f59090b7da90d30ea58625b1ffb39", size = 413443 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/bc/fed5f74b5d802cf9a03e83f60f18864e90e3aed7223adaca5ffb7a8d8d64/pydantic_core-2.27.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2d367ca20b2f14095a8f4fa1210f5a7b78b8a20009ecced6b12818f455b1e9fa", size = 1895938 }, - { url = "https://files.pythonhosted.org/packages/71/2a/185aff24ce844e39abb8dd680f4e959f0006944f4a8a0ea372d9f9ae2e53/pydantic_core-2.27.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:491a2b73db93fab69731eaee494f320faa4e093dbed776be1a829c2eb222c34c", size = 1815684 }, - { url = "https://files.pythonhosted.org/packages/c3/43/fafabd3d94d159d4f1ed62e383e264f146a17dd4d48453319fd782e7979e/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7969e133a6f183be60e9f6f56bfae753585680f3b7307a8e555a948d443cc05a", size = 1829169 }, - { url = "https://files.pythonhosted.org/packages/a2/d1/f2dfe1a2a637ce6800b799aa086d079998959f6f1215eb4497966efd2274/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3de9961f2a346257caf0aa508a4da705467f53778e9ef6fe744c038119737ef5", size = 1867227 }, - { url = "https://files.pythonhosted.org/packages/7d/39/e06fcbcc1c785daa3160ccf6c1c38fea31f5754b756e34b65f74e99780b5/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e2bb4d3e5873c37bb3dd58714d4cd0b0e6238cebc4177ac8fe878f8b3aa8e74c", size = 2037695 }, - { url = "https://files.pythonhosted.org/packages/7a/67/61291ee98e07f0650eb756d44998214231f50751ba7e13f4f325d95249ab/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:280d219beebb0752699480fe8f1dc61ab6615c2046d76b7ab7ee38858de0a4e7", size = 2741662 }, - { url = "https://files.pythonhosted.org/packages/32/90/3b15e31b88ca39e9e626630b4c4a1f5a0dfd09076366f4219429e6786076/pydantic_core-2.27.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47956ae78b6422cbd46f772f1746799cbb862de838fd8d1fbd34a82e05b0983a", size = 1993370 }, - { url = "https://files.pythonhosted.org/packages/ff/83/c06d333ee3a67e2e13e07794995c1535565132940715931c1c43bfc85b11/pydantic_core-2.27.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:14d4a5c49d2f009d62a2a7140d3064f686d17a5d1a268bc641954ba181880236", size = 1996813 }, - { url = "https://files.pythonhosted.org/packages/7c/f7/89be1c8deb6e22618a74f0ca0d933fdcb8baa254753b26b25ad3acff8f74/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:337b443af21d488716f8d0b6164de833e788aa6bd7e3a39c005febc1284f4962", size = 2005287 }, - { url = "https://files.pythonhosted.org/packages/b7/7d/8eb3e23206c00ef7feee17b83a4ffa0a623eb1a9d382e56e4aa46fd15ff2/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:03d0f86ea3184a12f41a2d23f7ccb79cdb5a18e06993f8a45baa8dfec746f0e9", size = 2128414 }, - { url = "https://files.pythonhosted.org/packages/4e/99/fe80f3ff8dd71a3ea15763878d464476e6cb0a2db95ff1c5c554133b6b83/pydantic_core-2.27.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7041c36f5680c6e0f08d922aed302e98b3745d97fe1589db0a3eebf6624523af", size = 2155301 }, - { url = "https://files.pythonhosted.org/packages/2b/a3/e50460b9a5789ca1451b70d4f52546fa9e2b420ba3bfa6100105c0559238/pydantic_core-2.27.2-cp310-cp310-win32.whl", hash = "sha256:50a68f3e3819077be2c98110c1f9dcb3817e93f267ba80a2c05bb4f8799e2ff4", size = 1816685 }, - { url = "https://files.pythonhosted.org/packages/57/4c/a8838731cb0f2c2a39d3535376466de6049034d7b239c0202a64aaa05533/pydantic_core-2.27.2-cp310-cp310-win_amd64.whl", hash = "sha256:e0fd26b16394ead34a424eecf8a31a1f5137094cabe84a1bcb10fa6ba39d3d31", size = 1982876 }, - { url = "https://files.pythonhosted.org/packages/c2/89/f3450af9d09d44eea1f2c369f49e8f181d742f28220f88cc4dfaae91ea6e/pydantic_core-2.27.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:8e10c99ef58cfdf2a66fc15d66b16c4a04f62bca39db589ae8cba08bc55331bc", size = 1893421 }, - { url = "https://files.pythonhosted.org/packages/9e/e3/71fe85af2021f3f386da42d291412e5baf6ce7716bd7101ea49c810eda90/pydantic_core-2.27.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26f32e0adf166a84d0cb63be85c562ca8a6fa8de28e5f0d92250c6b7e9e2aff7", size = 1814998 }, - { url = "https://files.pythonhosted.org/packages/a6/3c/724039e0d848fd69dbf5806894e26479577316c6f0f112bacaf67aa889ac/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c19d1ea0673cd13cc2f872f6c9ab42acc4e4f492a7ca9d3795ce2b112dd7e15", size = 1826167 }, - { url = "https://files.pythonhosted.org/packages/2b/5b/1b29e8c1fb5f3199a9a57c1452004ff39f494bbe9bdbe9a81e18172e40d3/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e68c4446fe0810e959cdff46ab0a41ce2f2c86d227d96dc3847af0ba7def306", size = 1865071 }, - { url = "https://files.pythonhosted.org/packages/89/6c/3985203863d76bb7d7266e36970d7e3b6385148c18a68cc8915fd8c84d57/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9640b0059ff4f14d1f37321b94061c6db164fbe49b334b31643e0528d100d99", size = 2036244 }, - { url = "https://files.pythonhosted.org/packages/0e/41/f15316858a246b5d723f7d7f599f79e37493b2e84bfc789e58d88c209f8a/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40d02e7d45c9f8af700f3452f329ead92da4c5f4317ca9b896de7ce7199ea459", size = 2737470 }, - { url = "https://files.pythonhosted.org/packages/a8/7c/b860618c25678bbd6d1d99dbdfdf0510ccb50790099b963ff78a124b754f/pydantic_core-2.27.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c1fd185014191700554795c99b347d64f2bb637966c4cfc16998a0ca700d048", size = 1992291 }, - { url = "https://files.pythonhosted.org/packages/bf/73/42c3742a391eccbeab39f15213ecda3104ae8682ba3c0c28069fbcb8c10d/pydantic_core-2.27.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d81d2068e1c1228a565af076598f9e7451712700b673de8f502f0334f281387d", size = 1994613 }, - { url = "https://files.pythonhosted.org/packages/94/7a/941e89096d1175d56f59340f3a8ebaf20762fef222c298ea96d36a6328c5/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1a4207639fb02ec2dbb76227d7c751a20b1a6b4bc52850568e52260cae64ca3b", size = 2002355 }, - { url = "https://files.pythonhosted.org/packages/6e/95/2359937a73d49e336a5a19848713555605d4d8d6940c3ec6c6c0ca4dcf25/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3de3ce3c9ddc8bbd88f6e0e304dea0e66d843ec9de1b0042b0911c1663ffd474", size = 2126661 }, - { url = "https://files.pythonhosted.org/packages/2b/4c/ca02b7bdb6012a1adef21a50625b14f43ed4d11f1fc237f9d7490aa5078c/pydantic_core-2.27.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:30c5f68ded0c36466acede341551106821043e9afaad516adfb6e8fa80a4e6a6", size = 2153261 }, - { url = "https://files.pythonhosted.org/packages/72/9d/a241db83f973049a1092a079272ffe2e3e82e98561ef6214ab53fe53b1c7/pydantic_core-2.27.2-cp311-cp311-win32.whl", hash = "sha256:c70c26d2c99f78b125a3459f8afe1aed4d9687c24fd677c6a4436bc042e50d6c", size = 1812361 }, - { url = "https://files.pythonhosted.org/packages/e8/ef/013f07248041b74abd48a385e2110aa3a9bbfef0fbd97d4e6d07d2f5b89a/pydantic_core-2.27.2-cp311-cp311-win_amd64.whl", hash = "sha256:08e125dbdc505fa69ca7d9c499639ab6407cfa909214d500897d02afb816e7cc", size = 1982484 }, - { url = "https://files.pythonhosted.org/packages/10/1c/16b3a3e3398fd29dca77cea0a1d998d6bde3902fa2706985191e2313cc76/pydantic_core-2.27.2-cp311-cp311-win_arm64.whl", hash = "sha256:26f0d68d4b235a2bae0c3fc585c585b4ecc51382db0e3ba402a22cbc440915e4", size = 1867102 }, - { url = "https://files.pythonhosted.org/packages/d6/74/51c8a5482ca447871c93e142d9d4a92ead74de6c8dc5e66733e22c9bba89/pydantic_core-2.27.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9e0c8cfefa0ef83b4da9588448b6d8d2a2bf1a53c3f1ae5fca39eb3061e2f0b0", size = 1893127 }, - { url = "https://files.pythonhosted.org/packages/d3/f3/c97e80721735868313c58b89d2de85fa80fe8dfeeed84dc51598b92a135e/pydantic_core-2.27.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:83097677b8e3bd7eaa6775720ec8e0405f1575015a463285a92bfdfe254529ef", size = 1811340 }, - { url = "https://files.pythonhosted.org/packages/9e/91/840ec1375e686dbae1bd80a9e46c26a1e0083e1186abc610efa3d9a36180/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:172fce187655fece0c90d90a678424b013f8fbb0ca8b036ac266749c09438cb7", size = 1822900 }, - { url = "https://files.pythonhosted.org/packages/f6/31/4240bc96025035500c18adc149aa6ffdf1a0062a4b525c932065ceb4d868/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:519f29f5213271eeeeb3093f662ba2fd512b91c5f188f3bb7b27bc5973816934", size = 1869177 }, - { url = "https://files.pythonhosted.org/packages/fa/20/02fbaadb7808be578317015c462655c317a77a7c8f0ef274bc016a784c54/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05e3a55d124407fffba0dd6b0c0cd056d10e983ceb4e5dbd10dda135c31071d6", size = 2038046 }, - { url = "https://files.pythonhosted.org/packages/06/86/7f306b904e6c9eccf0668248b3f272090e49c275bc488a7b88b0823444a4/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c3ed807c7b91de05e63930188f19e921d1fe90de6b4f5cd43ee7fcc3525cb8c", size = 2685386 }, - { url = "https://files.pythonhosted.org/packages/8d/f0/49129b27c43396581a635d8710dae54a791b17dfc50c70164866bbf865e3/pydantic_core-2.27.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fb4aadc0b9a0c063206846d603b92030eb6f03069151a625667f982887153e2", size = 1997060 }, - { url = "https://files.pythonhosted.org/packages/0d/0f/943b4af7cd416c477fd40b187036c4f89b416a33d3cc0ab7b82708a667aa/pydantic_core-2.27.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28ccb213807e037460326424ceb8b5245acb88f32f3d2777427476e1b32c48c4", size = 2004870 }, - { url = "https://files.pythonhosted.org/packages/35/40/aea70b5b1a63911c53a4c8117c0a828d6790483f858041f47bab0b779f44/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:de3cd1899e2c279b140adde9357c4495ed9d47131b4a4eaff9052f23398076b3", size = 1999822 }, - { url = "https://files.pythonhosted.org/packages/f2/b3/807b94fd337d58effc5498fd1a7a4d9d59af4133e83e32ae39a96fddec9d/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:220f892729375e2d736b97d0e51466252ad84c51857d4d15f5e9692f9ef12be4", size = 2130364 }, - { url = "https://files.pythonhosted.org/packages/fc/df/791c827cd4ee6efd59248dca9369fb35e80a9484462c33c6649a8d02b565/pydantic_core-2.27.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a0fcd29cd6b4e74fe8ddd2c90330fd8edf2e30cb52acda47f06dd615ae72da57", size = 2158303 }, - { url = "https://files.pythonhosted.org/packages/9b/67/4e197c300976af185b7cef4c02203e175fb127e414125916bf1128b639a9/pydantic_core-2.27.2-cp312-cp312-win32.whl", hash = "sha256:1e2cb691ed9834cd6a8be61228471d0a503731abfb42f82458ff27be7b2186fc", size = 1834064 }, - { url = "https://files.pythonhosted.org/packages/1f/ea/cd7209a889163b8dcca139fe32b9687dd05249161a3edda62860430457a5/pydantic_core-2.27.2-cp312-cp312-win_amd64.whl", hash = "sha256:cc3f1a99a4f4f9dd1de4fe0312c114e740b5ddead65bb4102884b384c15d8bc9", size = 1989046 }, - { url = "https://files.pythonhosted.org/packages/bc/49/c54baab2f4658c26ac633d798dab66b4c3a9bbf47cff5284e9c182f4137a/pydantic_core-2.27.2-cp312-cp312-win_arm64.whl", hash = "sha256:3911ac9284cd8a1792d3cb26a2da18f3ca26c6908cc434a18f730dc0db7bfa3b", size = 1885092 }, - { url = "https://files.pythonhosted.org/packages/41/b1/9bc383f48f8002f99104e3acff6cba1231b29ef76cfa45d1506a5cad1f84/pydantic_core-2.27.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d14bd329640e63852364c306f4d23eb744e0f8193148d4044dd3dacdaacbd8b", size = 1892709 }, - { url = "https://files.pythonhosted.org/packages/10/6c/e62b8657b834f3eb2961b49ec8e301eb99946245e70bf42c8817350cbefc/pydantic_core-2.27.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82f91663004eb8ed30ff478d77c4d1179b3563df6cdb15c0817cd1cdaf34d154", size = 1811273 }, - { url = "https://files.pythonhosted.org/packages/ba/15/52cfe49c8c986e081b863b102d6b859d9defc63446b642ccbbb3742bf371/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71b24c7d61131bb83df10cc7e687433609963a944ccf45190cfc21e0887b08c9", size = 1823027 }, - { url = "https://files.pythonhosted.org/packages/b1/1c/b6f402cfc18ec0024120602bdbcebc7bdd5b856528c013bd4d13865ca473/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fa8e459d4954f608fa26116118bb67f56b93b209c39b008277ace29937453dc9", size = 1868888 }, - { url = "https://files.pythonhosted.org/packages/bd/7b/8cb75b66ac37bc2975a3b7de99f3c6f355fcc4d89820b61dffa8f1e81677/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce8918cbebc8da707ba805b7fd0b382816858728ae7fe19a942080c24e5b7cd1", size = 2037738 }, - { url = "https://files.pythonhosted.org/packages/c8/f1/786d8fe78970a06f61df22cba58e365ce304bf9b9f46cc71c8c424e0c334/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eda3f5c2a021bbc5d976107bb302e0131351c2ba54343f8a496dc8783d3d3a6a", size = 2685138 }, - { url = "https://files.pythonhosted.org/packages/a6/74/d12b2cd841d8724dc8ffb13fc5cef86566a53ed358103150209ecd5d1999/pydantic_core-2.27.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8086fa684c4775c27f03f062cbb9eaa6e17f064307e86b21b9e0abc9c0f02e", size = 1997025 }, - { url = "https://files.pythonhosted.org/packages/a0/6e/940bcd631bc4d9a06c9539b51f070b66e8f370ed0933f392db6ff350d873/pydantic_core-2.27.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d9b3388db186ba0c099a6d20f0604a44eabdeef1777ddd94786cdae158729e4", size = 2004633 }, - { url = "https://files.pythonhosted.org/packages/50/cc/a46b34f1708d82498c227d5d80ce615b2dd502ddcfd8376fc14a36655af1/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7a66efda2387de898c8f38c0cf7f14fca0b51a8ef0b24bfea5849f1b3c95af27", size = 1999404 }, - { url = "https://files.pythonhosted.org/packages/ca/2d/c365cfa930ed23bc58c41463bae347d1005537dc8db79e998af8ba28d35e/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:18a101c168e4e092ab40dbc2503bdc0f62010e95d292b27827871dc85450d7ee", size = 2130130 }, - { url = "https://files.pythonhosted.org/packages/f4/d7/eb64d015c350b7cdb371145b54d96c919d4db516817f31cd1c650cae3b21/pydantic_core-2.27.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ba5dd002f88b78a4215ed2f8ddbdf85e8513382820ba15ad5ad8955ce0ca19a1", size = 2157946 }, - { url = "https://files.pythonhosted.org/packages/a4/99/bddde3ddde76c03b65dfd5a66ab436c4e58ffc42927d4ff1198ffbf96f5f/pydantic_core-2.27.2-cp313-cp313-win32.whl", hash = "sha256:1ebaf1d0481914d004a573394f4be3a7616334be70261007e47c2a6fe7e50130", size = 1834387 }, - { url = "https://files.pythonhosted.org/packages/71/47/82b5e846e01b26ac6f1893d3c5f9f3a2eb6ba79be26eef0b759b4fe72946/pydantic_core-2.27.2-cp313-cp313-win_amd64.whl", hash = "sha256:953101387ecf2f5652883208769a79e48db18c6df442568a0b5ccd8c2723abee", size = 1990453 }, - { url = "https://files.pythonhosted.org/packages/51/b2/b2b50d5ecf21acf870190ae5d093602d95f66c9c31f9d5de6062eb329ad1/pydantic_core-2.27.2-cp313-cp313-win_arm64.whl", hash = "sha256:ac4dbfd1691affb8f48c2c13241a2e3b60ff23247cbcf981759c768b6633cf8b", size = 1885186 }, - { url = "https://files.pythonhosted.org/packages/43/53/13e9917fc69c0a4aea06fd63ed6a8d6cda9cf140ca9584d49c1650b0ef5e/pydantic_core-2.27.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:d3e8d504bdd3f10835468f29008d72fc8359d95c9c415ce6e767203db6127506", size = 1899595 }, - { url = "https://files.pythonhosted.org/packages/f4/20/26c549249769ed84877f862f7bb93f89a6ee08b4bee1ed8781616b7fbb5e/pydantic_core-2.27.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:521eb9b7f036c9b6187f0b47318ab0d7ca14bd87f776240b90b21c1f4f149320", size = 1775010 }, - { url = "https://files.pythonhosted.org/packages/35/eb/8234e05452d92d2b102ffa1b56d801c3567e628fdc63f02080fdfc68fd5e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85210c4d99a0114f5a9481b44560d7d1e35e32cc5634c656bc48e590b669b145", size = 1830727 }, - { url = "https://files.pythonhosted.org/packages/8f/df/59f915c8b929d5f61e5a46accf748a87110ba145156f9326d1a7d28912b2/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d716e2e30c6f140d7560ef1538953a5cd1a87264c737643d481f2779fc247fe1", size = 1868393 }, - { url = "https://files.pythonhosted.org/packages/d5/52/81cf4071dca654d485c277c581db368b0c95b2b883f4d7b736ab54f72ddf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f66d89ba397d92f840f8654756196d93804278457b5fbede59598a1f9f90b228", size = 2040300 }, - { url = "https://files.pythonhosted.org/packages/9c/00/05197ce1614f5c08d7a06e1d39d5d8e704dc81971b2719af134b844e2eaf/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:669e193c1c576a58f132e3158f9dfa9662969edb1a250c54d8fa52590045f046", size = 2738785 }, - { url = "https://files.pythonhosted.org/packages/f7/a3/5f19bc495793546825ab160e530330c2afcee2281c02b5ffafd0b32ac05e/pydantic_core-2.27.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdbe7629b996647b99c01b37f11170a57ae675375b14b8c13b8518b8320ced5", size = 1996493 }, - { url = "https://files.pythonhosted.org/packages/ed/e8/e0102c2ec153dc3eed88aea03990e1b06cfbca532916b8a48173245afe60/pydantic_core-2.27.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d262606bf386a5ba0b0af3b97f37c83d7011439e3dc1a9298f21efb292e42f1a", size = 1998544 }, - { url = "https://files.pythonhosted.org/packages/fb/a3/4be70845b555bd80aaee9f9812a7cf3df81550bce6dadb3cfee9c5d8421d/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:cabb9bcb7e0d97f74df8646f34fc76fbf793b7f6dc2438517d7a9e50eee4f14d", size = 2007449 }, - { url = "https://files.pythonhosted.org/packages/e3/9f/b779ed2480ba355c054e6d7ea77792467631d674b13d8257085a4bc7dcda/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:d2d63f1215638d28221f664596b1ccb3944f6e25dd18cd3b86b0a4c408d5ebb9", size = 2129460 }, - { url = "https://files.pythonhosted.org/packages/a0/f0/a6ab0681f6e95260c7fbf552874af7302f2ea37b459f9b7f00698f875492/pydantic_core-2.27.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bca101c00bff0adb45a833f8451b9105d9df18accb8743b08107d7ada14bd7da", size = 2159609 }, - { url = "https://files.pythonhosted.org/packages/8a/2b/e1059506795104349712fbca647b18b3f4a7fd541c099e6259717441e1e0/pydantic_core-2.27.2-cp38-cp38-win32.whl", hash = "sha256:f6f8e111843bbb0dee4cb6594cdc73e79b3329b526037ec242a3e49012495b3b", size = 1819886 }, - { url = "https://files.pythonhosted.org/packages/aa/6d/df49c17f024dfc58db0bacc7b03610058018dd2ea2eaf748ccbada4c3d06/pydantic_core-2.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:fd1aea04935a508f62e0d0ef1f5ae968774a32afc306fb8545e06f5ff5cdf3ad", size = 1980773 }, - { url = "https://files.pythonhosted.org/packages/27/97/3aef1ddb65c5ccd6eda9050036c956ff6ecbfe66cb7eb40f280f121a5bb0/pydantic_core-2.27.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c10eb4f1659290b523af58fa7cffb452a61ad6ae5613404519aee4bfbf1df993", size = 1896475 }, - { url = "https://files.pythonhosted.org/packages/ad/d3/5668da70e373c9904ed2f372cb52c0b996426f302e0dee2e65634c92007d/pydantic_core-2.27.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef592d4bad47296fb11f96cd7dc898b92e795032b4894dfb4076cfccd43a9308", size = 1772279 }, - { url = "https://files.pythonhosted.org/packages/8a/9e/e44b8cb0edf04a2f0a1f6425a65ee089c1d6f9c4c2dcab0209127b6fdfc2/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c61709a844acc6bf0b7dce7daae75195a10aac96a596ea1b776996414791ede4", size = 1829112 }, - { url = "https://files.pythonhosted.org/packages/1c/90/1160d7ac700102effe11616e8119e268770f2a2aa5afb935f3ee6832987d/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c5f762659e47fdb7b16956c71598292f60a03aa92f8b6351504359dbdba6cf", size = 1866780 }, - { url = "https://files.pythonhosted.org/packages/ee/33/13983426df09a36d22c15980008f8d9c77674fc319351813b5a2739b70f3/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c9775e339e42e79ec99c441d9730fccf07414af63eac2f0e48e08fd38a64d76", size = 2037943 }, - { url = "https://files.pythonhosted.org/packages/01/d7/ced164e376f6747e9158c89988c293cd524ab8d215ae4e185e9929655d5c/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:57762139821c31847cfb2df63c12f725788bd9f04bc2fb392790959b8f70f118", size = 2740492 }, - { url = "https://files.pythonhosted.org/packages/8b/1f/3dc6e769d5b7461040778816aab2b00422427bcaa4b56cc89e9c653b2605/pydantic_core-2.27.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0d1e85068e818c73e048fe28cfc769040bb1f475524f4745a5dc621f75ac7630", size = 1995714 }, - { url = "https://files.pythonhosted.org/packages/07/d7/a0bd09bc39283530b3f7c27033a814ef254ba3bd0b5cfd040b7abf1fe5da/pydantic_core-2.27.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:097830ed52fd9e427942ff3b9bc17fab52913b2f50f2880dc4a5611446606a54", size = 1997163 }, - { url = "https://files.pythonhosted.org/packages/2d/bb/2db4ad1762e1c5699d9b857eeb41959191980de6feb054e70f93085e1bcd/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044a50963a614ecfae59bb1eaf7ea7efc4bc62f49ed594e18fa1e5d953c40e9f", size = 2005217 }, - { url = "https://files.pythonhosted.org/packages/53/5f/23a5a3e7b8403f8dd8fc8a6f8b49f6b55c7d715b77dcf1f8ae919eeb5628/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:4e0b4220ba5b40d727c7f879eac379b822eee5d8fff418e9d3381ee45b3b0362", size = 2127899 }, - { url = "https://files.pythonhosted.org/packages/c2/ae/aa38bb8dd3d89c2f1d8362dd890ee8f3b967330821d03bbe08fa01ce3766/pydantic_core-2.27.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5e4f4bb20d75e9325cc9696c6802657b58bc1dbbe3022f32cc2b2b632c3fbb96", size = 2155726 }, - { url = "https://files.pythonhosted.org/packages/98/61/4f784608cc9e98f70839187117ce840480f768fed5d386f924074bf6213c/pydantic_core-2.27.2-cp39-cp39-win32.whl", hash = "sha256:cca63613e90d001b9f2f9a9ceb276c308bfa2a43fafb75c8031c4f66039e8c6e", size = 1817219 }, - { url = "https://files.pythonhosted.org/packages/57/82/bb16a68e4a1a858bb3768c2c8f1ff8d8978014e16598f001ea29a25bf1d1/pydantic_core-2.27.2-cp39-cp39-win_amd64.whl", hash = "sha256:77d1bca19b0f7021b3a982e6f903dcd5b2b06076def36a652e3907f596e29f67", size = 1985382 }, - { url = "https://files.pythonhosted.org/packages/46/72/af70981a341500419e67d5cb45abe552a7c74b66326ac8877588488da1ac/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2bf14caea37e91198329b828eae1618c068dfb8ef17bb33287a7ad4b61ac314e", size = 1891159 }, - { url = "https://files.pythonhosted.org/packages/ad/3d/c5913cccdef93e0a6a95c2d057d2c2cba347815c845cda79ddd3c0f5e17d/pydantic_core-2.27.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0cb791f5b45307caae8810c2023a184c74605ec3bcbb67d13846c28ff731ff8", size = 1768331 }, - { url = "https://files.pythonhosted.org/packages/f6/f0/a3ae8fbee269e4934f14e2e0e00928f9346c5943174f2811193113e58252/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:688d3fd9fcb71f41c4c015c023d12a79d1c4c0732ec9eb35d96e3388a120dcf3", size = 1822467 }, - { url = "https://files.pythonhosted.org/packages/d7/7a/7bbf241a04e9f9ea24cd5874354a83526d639b02674648af3f350554276c/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d591580c34f4d731592f0e9fe40f9cc1b430d297eecc70b962e93c5c668f15f", size = 1979797 }, - { url = "https://files.pythonhosted.org/packages/4f/5f/4784c6107731f89e0005a92ecb8a2efeafdb55eb992b8e9d0a2be5199335/pydantic_core-2.27.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:82f986faf4e644ffc189a7f1aafc86e46ef70372bb153e7001e8afccc6e54133", size = 1987839 }, - { url = "https://files.pythonhosted.org/packages/6d/a7/61246562b651dff00de86a5f01b6e4befb518df314c54dec187a78d81c84/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:bec317a27290e2537f922639cafd54990551725fc844249e64c523301d0822fc", size = 1998861 }, - { url = "https://files.pythonhosted.org/packages/86/aa/837821ecf0c022bbb74ca132e117c358321e72e7f9702d1b6a03758545e2/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:0296abcb83a797db256b773f45773da397da75a08f5fcaef41f2044adec05f50", size = 2116582 }, - { url = "https://files.pythonhosted.org/packages/81/b0/5e74656e95623cbaa0a6278d16cf15e10a51f6002e3ec126541e95c29ea3/pydantic_core-2.27.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:0d75070718e369e452075a6017fbf187f788e17ed67a3abd47fa934d001863d9", size = 2151985 }, - { url = "https://files.pythonhosted.org/packages/63/37/3e32eeb2a451fddaa3898e2163746b0cffbbdbb4740d38372db0490d67f3/pydantic_core-2.27.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7e17b560be3c98a8e3aa66ce828bdebb9e9ac6ad5466fba92eb74c4c95cb1151", size = 2004715 }, - { url = "https://files.pythonhosted.org/packages/29/0e/dcaea00c9dbd0348b723cae82b0e0c122e0fa2b43fa933e1622fd237a3ee/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c33939a82924da9ed65dab5a65d427205a73181d8098e79b6b426bdf8ad4e656", size = 1891733 }, - { url = "https://files.pythonhosted.org/packages/86/d3/e797bba8860ce650272bda6383a9d8cad1d1c9a75a640c9d0e848076f85e/pydantic_core-2.27.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:00bad2484fa6bda1e216e7345a798bd37c68fb2d97558edd584942aa41b7d278", size = 1768375 }, - { url = "https://files.pythonhosted.org/packages/41/f7/f847b15fb14978ca2b30262548f5fc4872b2724e90f116393eb69008299d/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c817e2b40aba42bac6f457498dacabc568c3b7a986fc9ba7c8d9d260b71485fb", size = 1822307 }, - { url = "https://files.pythonhosted.org/packages/9c/63/ed80ec8255b587b2f108e514dc03eed1546cd00f0af281e699797f373f38/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:251136cdad0cb722e93732cb45ca5299fb56e1344a833640bf93b2803f8d1bfd", size = 1979971 }, - { url = "https://files.pythonhosted.org/packages/a9/6d/6d18308a45454a0de0e975d70171cadaf454bc7a0bf86b9c7688e313f0bb/pydantic_core-2.27.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d2088237af596f0a524d3afc39ab3b036e8adb054ee57cbb1dcf8e09da5b29cc", size = 1987616 }, - { url = "https://files.pythonhosted.org/packages/82/8a/05f8780f2c1081b800a7ca54c1971e291c2d07d1a50fb23c7e4aef4ed403/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d4041c0b966a84b4ae7a09832eb691a35aec90910cd2dbe7a208de59be77965b", size = 1998943 }, - { url = "https://files.pythonhosted.org/packages/5e/3e/fe5b6613d9e4c0038434396b46c5303f5ade871166900b357ada4766c5b7/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:8083d4e875ebe0b864ffef72a4304827015cff328a1be6e22cc850753bfb122b", size = 2116654 }, - { url = "https://files.pythonhosted.org/packages/db/ad/28869f58938fad8cc84739c4e592989730bfb69b7c90a8fff138dff18e1e/pydantic_core-2.27.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f141ee28a0ad2123b6611b6ceff018039df17f32ada8b534e6aa039545a3efb2", size = 2152292 }, - { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, -] - -[[package]] -name = "pydantic-core" -version = "2.41.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298 }, - { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475 }, - { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815 }, - { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567 }, - { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442 }, - { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956 }, - { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253 }, - { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050 }, - { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178 }, - { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833 }, - { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156 }, - { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378 }, - { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622 }, - { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873 }, - { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826 }, - { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869 }, - { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890 }, - { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740 }, - { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021 }, - { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378 }, - { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761 }, - { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303 }, - { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355 }, - { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875 }, - { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549 }, - { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305 }, - { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902 }, - { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990 }, - { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003 }, - { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200 }, - { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578 }, - { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504 }, - { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816 }, - { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366 }, - { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698 }, - { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603 }, - { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591 }, - { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068 }, - { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908 }, - { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145 }, - { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179 }, - { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403 }, - { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206 }, - { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307 }, - { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258 }, - { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917 }, - { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186 }, - { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164 }, - { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146 }, - { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788 }, - { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133 }, - { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852 }, - { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679 }, - { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766 }, - { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005 }, - { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622 }, - { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725 }, - { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040 }, - { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691 }, - { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897 }, - { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302 }, - { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877 }, - { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680 }, - { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960 }, - { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102 }, - { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039 }, - { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126 }, - { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489 }, - { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288 }, - { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255 }, - { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760 }, - { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092 }, - { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385 }, - { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832 }, - { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585 }, - { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078 }, - { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914 }, - { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560 }, - { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244 }, - { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955 }, - { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906 }, - { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607 }, - { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769 }, - { url = "https://files.pythonhosted.org/packages/54/db/160dffb57ed9a3705c4cbcbff0ac03bdae45f1ca7d58ab74645550df3fbd/pydantic_core-2.41.5-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:8bfeaf8735be79f225f3fefab7f941c712aaca36f1128c9d7e2352ee1aa87bdf", size = 2107999 }, - { url = "https://files.pythonhosted.org/packages/a3/7d/88e7de946f60d9263cc84819f32513520b85c0f8322f9b8f6e4afc938383/pydantic_core-2.41.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:346285d28e4c8017da95144c7f3acd42740d637ff41946af5ce6e5e420502dd5", size = 1929745 }, - { url = "https://files.pythonhosted.org/packages/d5/c2/aef51e5b283780e85e99ff19db0f05842d2d4a8a8cd15e63b0280029b08f/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a75dafbf87d6276ddc5b2bf6fae5254e3d0876b626eb24969a574fff9149ee5d", size = 1920220 }, - { url = "https://files.pythonhosted.org/packages/c7/97/492ab10f9ac8695cd76b2fdb24e9e61f394051df71594e9bcc891c9f586e/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b93a4d08587e2b7e7882de461e82b6ed76d9026ce91ca7915e740ecc7855f60", size = 2067296 }, - { url = "https://files.pythonhosted.org/packages/ec/23/984149650e5269c59a2a4c41d234a9570adc68ab29981825cfaf4cfad8f4/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8465ab91a4bd96d36dde3263f06caa6a8a6019e4113f24dc753d79a8b3a3f82", size = 2231548 }, - { url = "https://files.pythonhosted.org/packages/71/0c/85bcbb885b9732c28bec67a222dbed5ed2d77baee1f8bba2002e8cd00c5c/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:299e0a22e7ae2b85c1a57f104538b2656e8ab1873511fd718a1c1c6f149b77b5", size = 2362571 }, - { url = "https://files.pythonhosted.org/packages/c0/4a/412d2048be12c334003e9b823a3fa3d038e46cc2d64dd8aab50b31b65499/pydantic_core-2.41.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:707625ef0983fcfb461acfaf14de2067c5942c6bb0f3b4c99158bed6fedd3cf3", size = 2068175 }, - { url = "https://files.pythonhosted.org/packages/73/f4/c58b6a776b502d0a5540ad02e232514285513572060f0d78f7832ca3c98b/pydantic_core-2.41.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f41eb9797986d6ebac5e8edff36d5cef9de40def462311b3eb3eeded1431e425", size = 2177203 }, - { url = "https://files.pythonhosted.org/packages/ed/ae/f06ea4c7e7a9eead3d165e7623cd2ea0cb788e277e4f935af63fc98fa4e6/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0384e2e1021894b1ff5a786dbf94771e2986ebe2869533874d7e43bc79c6f504", size = 2148191 }, - { url = "https://files.pythonhosted.org/packages/c1/57/25a11dcdc656bf5f8b05902c3c2934ac3ea296257cc4a3f79a6319e61856/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:f0cd744688278965817fd0839c4a4116add48d23890d468bc436f78beb28abf5", size = 2343907 }, - { url = "https://files.pythonhosted.org/packages/96/82/e33d5f4933d7a03327c0c43c65d575e5919d4974ffc026bc917a5f7b9f61/pydantic_core-2.41.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:753e230374206729bf0a807954bcc6c150d3743928a73faffee51ac6557a03c3", size = 2322174 }, - { url = "https://files.pythonhosted.org/packages/81/45/4091be67ce9f469e81656f880f3506f6a5624121ec5eb3eab37d7581897d/pydantic_core-2.41.5-cp39-cp39-win32.whl", hash = "sha256:873e0d5b4fb9b89ef7c2d2a963ea7d02879d9da0da8d9d4933dee8ee86a8b460", size = 1990353 }, - { url = "https://files.pythonhosted.org/packages/44/8a/a98aede18db6e9cd5d66bcacd8a409fcf8134204cdede2e7de35c5a2c5ef/pydantic_core-2.41.5-cp39-cp39-win_amd64.whl", hash = "sha256:e4f4a984405e91527a0d62649ee21138f8e3d0ef103be488c1dc11a80d7f184b", size = 2015698 }, - { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351 }, - { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363 }, - { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615 }, - { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369 }, - { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218 }, - { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951 }, - { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428 }, - { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009 }, - { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980 }, - { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865 }, - { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256 }, - { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762 }, - { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141 }, - { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317 }, - { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992 }, - { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302 }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 }, -] - -[[package]] -name = "pyreadline3" -version = "3.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/49/4cea918a08f02817aabae639e3d0ac046fef9f9180518a3ad394e22da148/pyreadline3-3.5.4.tar.gz", hash = "sha256:8d57d53039a1c75adba8e50dd3d992b28143480816187ea5efbd5c78e6c885b7", size = 99839 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/dc/491b7661614ab97483abf2056be1deee4dc2490ecbf7bff9ab5cdbac86e1/pyreadline3-3.5.4-py3-none-any.whl", hash = "sha256:eaf8e6cc3c49bcccf145fc6067ba8643d1df34d604a1ec0eccbf7a18e6d3fae6", size = 83178 }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, -] - -[[package]] -name = "python-ulid" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/e8/8b/0580d8ee0a73a3f3869488856737c429cbaa08b63c3506275f383c4771a8/python-ulid-1.1.0.tar.gz", hash = "sha256:5fb5e4a91db8ca93e8938a613360b3def299b60d41f847279a8c39c9b2e9c65e", size = 19992 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/8e/c30b08ee9b8dc9b4a10e782c2a7fd5de55388201ddebfe0f7ab99dfbb349/python_ulid-1.1.0-py3-none-any.whl", hash = "sha256:88c952f6be133dbede19c907d72d26717d2691ec8421512b573144794d891e24", size = 9360 }, -] - -[[package]] -name = "python-ulid" -version = "3.1.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/40/7e/0d6c82b5ccc71e7c833aed43d9e8468e1f2ff0be1b3f657a6fcafbb8433d/python_ulid-3.1.0.tar.gz", hash = "sha256:ff0410a598bc5f6b01b602851a3296ede6f91389f913a5d5f8c496003836f636", size = 93175 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/a0/4ed6632b70a52de845df056654162acdebaf97c20e3212c559ac43e7216e/python_ulid-3.1.0-py3-none-any.whl", hash = "sha256:e2cdc979c8c877029b4b7a38a6fba3bc4578e4f109a308419ff4d3ccf0a46619", size = 11577 }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0d/a2/09f67a3589cb4320fb5ce90d3fd4c9752636b8b6ad8f34b54d76c5a54693/PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f", size = 186824 }, - { url = "https://files.pythonhosted.org/packages/02/72/d972384252432d57f248767556ac083793292a4adf4e2d85dfe785ec2659/PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4", size = 795069 }, - { url = "https://files.pythonhosted.org/packages/a7/3b/6c58ac0fa7c4e1b35e48024eb03d00817438310447f93ef4431673c24138/PyYAML-6.0.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3", size = 862585 }, - { url = "https://files.pythonhosted.org/packages/25/a2/b725b61ac76a75583ae7104b3209f75ea44b13cfd026aa535ece22b7f22e/PyYAML-6.0.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6", size = 806018 }, - { url = "https://files.pythonhosted.org/packages/6f/b0/b2227677b2d1036d84f5ee95eb948e7af53d59fe3e4328784e4d290607e0/PyYAML-6.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369", size = 802822 }, - { url = "https://files.pythonhosted.org/packages/99/a5/718a8ea22521e06ef19f91945766a892c5ceb1855df6adbde67d997ea7ed/PyYAML-6.0.3-cp38-cp38-win32.whl", hash = "sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295", size = 143744 }, - { url = "https://files.pythonhosted.org/packages/76/b2/2b69cee94c9eb215216fc05778675c393e3aa541131dc910df8e52c83776/PyYAML-6.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b", size = 160082 }, - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 }, - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, - { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450 }, - { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319 }, - { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631 }, - { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795 }, - { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767 }, - { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982 }, - { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677 }, - { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592 }, - { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777 }, -] - -[[package]] -name = "requests" -version = "2.32.4" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version < '3.9'" }, - { name = "charset-normalizer", marker = "python_full_version < '3.9'" }, - { name = "idna", marker = "python_full_version < '3.9'" }, - { name = "urllib3", version = "2.2.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.9'" }, - { name = "charset-normalizer", marker = "python_full_version >= '3.9'" }, - { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738 }, -] - -[[package]] -name = "rich" -version = "14.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "markdown-it-py", version = "4.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393 }, -] - -[[package]] -name = "setuptools" -version = "75.3.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/01/771ea46cce201dd42cff043a5eea929d1c030fb3d1c2ee2729d02ca7814c/setuptools-75.3.2.tar.gz", hash = "sha256:3c1383e1038b68556a382c1e8ded8887cd20141b0eb5708a6c8d277de49364f5", size = 1354489 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/15/65/3f0dba35760d902849d39d38c0a72767794b1963227b69a587f8a336d08c/setuptools-75.3.2-py3-none-any.whl", hash = "sha256:90ab613b6583fc02d5369cbca13ea26ea0e182d1df2d943ee9cbe81d4c61add9", size = 1251198 }, -] - -[[package]] -name = "setuptools" -version = "80.9.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, -] - -[[package]] -name = "sqlite-fts4" -version = "1.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/6d/9dad6c3b433ab8912ace969c66abd595f8e0a2ccccdb73602b1291dbda29/sqlite-fts4-1.0.3.tar.gz", hash = "sha256:78b05eeaf6680e9dbed8986bde011e9c086a06cb0c931b3cf7da94c214e8930c", size = 9718 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/29/0096e8b1811aaa78cfb296996f621f41120c21c2f5cd448ae1d54979d9fc/sqlite_fts4-1.0.3-py3-none-any.whl", hash = "sha256:0359edd8dea6fd73c848989e1e2b1f31a50fe5f9d7272299ff0e8dbaa62d035f", size = 9972 }, -] - -[[package]] -name = "sqlite-migrate" -version = "0.1b0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "sqlite-utils", version = "3.38", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "sqlite-utils", version = "3.39", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/13/86/1463a00d3c4bdb707c0ed4077d17687465a0aa9444593f66f6c4b49e39b5/sqlite-migrate-0.1b0.tar.gz", hash = "sha256:8d502b3ca4b9c45e56012bd35c03d23235f0823c976d4ce940cbb40e33087ded", size = 10736 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/92/994545b912e6d6feb40323047f02ca039321e690aa2c27afcd5c4105e37b/sqlite_migrate-0.1b0-py3-none-any.whl", hash = "sha256:a4125e35e1de3dc56b6b6ec60e9833ce0ce20192b929ddcb2d4246c5098859c6", size = 9986 }, -] - -[[package]] -name = "sqlite-utils" -version = "3.38" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.9.*'", - "python_full_version < '3.9'", -] -dependencies = [ - { name = "click", version = "8.1.8", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "click-default-group", marker = "python_full_version < '3.10'" }, - { name = "pluggy", version = "1.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.9.*'" }, - { name = "python-dateutil", marker = "python_full_version < '3.10'" }, - { name = "sqlite-fts4", marker = "python_full_version < '3.10'" }, - { name = "tabulate", marker = "python_full_version < '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/51/43/ce9183a21911e0b73248c8fb83f8b8038515cb80053912c2a009e9765564/sqlite_utils-3.38.tar.gz", hash = "sha256:1ae77b931384052205a15478d429464f6c67a3ac3b4eafd3c674ac900f623aab", size = 214449 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/eb/f8e8e827805f810838efff3311cccd2601238c5fa3fc35c1f878709e161b/sqlite_utils-3.38-py3-none-any.whl", hash = "sha256:8a27441015c3b2ef475f555861f7a2592f73bc60d247af9803a11b65fc605bf9", size = 68183 }, -] - -[[package]] -name = "sqlite-utils" -version = "3.39" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", -] -dependencies = [ - { name = "click", version = "8.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "click-default-group", marker = "python_full_version >= '3.10'" }, - { name = "pip", version = "25.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "pluggy", version = "1.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.10'" }, - { name = "sqlite-fts4", marker = "python_full_version >= '3.10'" }, - { name = "tabulate", marker = "python_full_version >= '3.10'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b3/e3/6b1106349e2576c18409b27bd3b16f193b1cf38220d98ad22aa454c5e075/sqlite_utils-3.39.tar.gz", hash = "sha256:bfa2eac29b3e3eb5c9647283797527febcf4efd4a9bbb31d979a14a11ef9dbcd", size = 215324 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/33/7e01d2f6b8c778529dfae9045c4f46b33ba145c3d401fa95b07f599e7403/sqlite_utils-3.39-py3-none-any.whl", hash = "sha256:349c099c0cd60d4ee9139a24d5c9cb64af3906c3e90832fcbbd74da49333374d", size = 68451 }, -] - -[[package]] -name = "tabulate" -version = "0.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252 }, -] - -[[package]] -name = "tqdm" -version = "4.67.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, -] - -[[package]] -name = "typing-extensions" -version = "4.13.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611 }, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, -] - -[[package]] -name = "yarl" -version = "1.15.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.9'", -] -dependencies = [ - { name = "idna", marker = "python_full_version < '3.9'" }, - { name = "multidict", version = "6.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, - { name = "propcache", version = "0.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/e1/d5427a061819c9f885f58bb0467d02a523f1aec19f9e5f9c82ce950d90d3/yarl-1.15.2.tar.gz", hash = "sha256:a39c36f4218a5bb668b4f06874d676d35a035ee668e6e7e3538835c703634b84", size = 169318 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/f8/6b1bbc6f597d8937ad8661c042aa6bdbbe46a3a6e38e2c04214b9c82e804/yarl-1.15.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e4ee8b8639070ff246ad3649294336b06db37a94bdea0d09ea491603e0be73b8", size = 136479 }, - { url = "https://files.pythonhosted.org/packages/61/e0/973c0d16b1cb710d318b55bd5d019a1ecd161d28670b07d8d9df9a83f51f/yarl-1.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a7cf963a357c5f00cb55b1955df8bbe68d2f2f65de065160a1c26b85a1e44172", size = 88671 }, - { url = "https://files.pythonhosted.org/packages/16/df/241cfa1cf33b96da2c8773b76fe3ee58e04cb09ecfe794986ec436ae97dc/yarl-1.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:43ebdcc120e2ca679dba01a779333a8ea76b50547b55e812b8b92818d604662c", size = 86578 }, - { url = "https://files.pythonhosted.org/packages/02/a4/ee2941d1f93600d921954a0850e20581159772304e7de49f60588e9128a2/yarl-1.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3433da95b51a75692dcf6cc8117a31410447c75a9a8187888f02ad45c0a86c50", size = 307212 }, - { url = "https://files.pythonhosted.org/packages/08/64/2e6561af430b092b21c7a867ae3079f62e1532d3e51fee765fd7a74cef6c/yarl-1.15.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:38d0124fa992dbacd0c48b1b755d3ee0a9f924f427f95b0ef376556a24debf01", size = 321589 }, - { url = "https://files.pythonhosted.org/packages/f8/af/056ab318a7117fa70f6ab502ff880e47af973948d1d123aff397cd68499c/yarl-1.15.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ded1b1803151dd0f20a8945508786d57c2f97a50289b16f2629f85433e546d47", size = 319443 }, - { url = "https://files.pythonhosted.org/packages/99/d1/051b0bc2c90c9a2618bab10a9a9a61a96ddb28c7c54161a5c97f9e625205/yarl-1.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ace4cad790f3bf872c082366c9edd7f8f8f77afe3992b134cfc810332206884f", size = 310324 }, - { url = "https://files.pythonhosted.org/packages/23/1b/16df55016f9ac18457afda165031086bce240d8bcf494501fb1164368617/yarl-1.15.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c77494a2f2282d9bbbbcab7c227a4d1b4bb829875c96251f66fb5f3bae4fb053", size = 300428 }, - { url = "https://files.pythonhosted.org/packages/83/a5/5188d1c575139a8dfd90d463d56f831a018f41f833cdf39da6bd8a72ee08/yarl-1.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b7f227ca6db5a9fda0a2b935a2ea34a7267589ffc63c8045f0e4edb8d8dcf956", size = 307079 }, - { url = "https://files.pythonhosted.org/packages/ba/4e/2497f8f2b34d1a261bebdbe00066242eacc9a7dccd4f02ddf0995014290a/yarl-1.15.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:31561a5b4d8dbef1559b3600b045607cf804bae040f64b5f5bca77da38084a8a", size = 305835 }, - { url = "https://files.pythonhosted.org/packages/91/db/40a347e1f8086e287a53c72dc333198816885bc770e3ecafcf5eaeb59311/yarl-1.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3e52474256a7db9dcf3c5f4ca0b300fdea6c21cca0148c8891d03a025649d935", size = 311033 }, - { url = "https://files.pythonhosted.org/packages/2f/a6/1500e1e694616c25eed6bf8c1aacc0943f124696d2421a07ae5e9ee101a5/yarl-1.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:0e1af74a9529a1137c67c887ed9cde62cff53aa4d84a3adbec329f9ec47a3936", size = 326317 }, - { url = "https://files.pythonhosted.org/packages/37/db/868d4b59cc76932ce880cc9946cd0ae4ab111a718494a94cb50dd5b67d82/yarl-1.15.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:15c87339490100c63472a76d87fe7097a0835c705eb5ae79fd96e343473629ed", size = 324196 }, - { url = "https://files.pythonhosted.org/packages/bd/41/b6c917c2fde2601ee0b45c82a0c502dc93e746dea469d3a6d1d0a24749e8/yarl-1.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:74abb8709ea54cc483c4fb57fb17bb66f8e0f04438cff6ded322074dbd17c7ec", size = 317023 }, - { url = "https://files.pythonhosted.org/packages/b0/85/2cde6b656fd83c474f19606af3f7a3e94add8988760c87a101ee603e7b8f/yarl-1.15.2-cp310-cp310-win32.whl", hash = "sha256:ffd591e22b22f9cb48e472529db6a47203c41c2c5911ff0a52e85723196c0d75", size = 78136 }, - { url = "https://files.pythonhosted.org/packages/ef/3c/4414901b0588427870002b21d790bd1fad142a9a992a22e5037506d0ed9d/yarl-1.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:1695497bb2a02a6de60064c9f077a4ae9c25c73624e0d43e3aa9d16d983073c2", size = 84231 }, - { url = "https://files.pythonhosted.org/packages/4a/59/3ae125c97a2a8571ea16fdf59fcbd288bc169e0005d1af9946a90ea831d9/yarl-1.15.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9fcda20b2de7042cc35cf911702fa3d8311bd40055a14446c1e62403684afdc5", size = 136492 }, - { url = "https://files.pythonhosted.org/packages/f9/2b/efa58f36b582db45b94c15e87803b775eb8a4ca0db558121a272e67f3564/yarl-1.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0545de8c688fbbf3088f9e8b801157923be4bf8e7b03e97c2ecd4dfa39e48e0e", size = 88614 }, - { url = "https://files.pythonhosted.org/packages/82/69/eb73c0453a2ff53194df485dc7427d54e6cb8d1180fcef53251a8e24d069/yarl-1.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fbda058a9a68bec347962595f50546a8a4a34fd7b0654a7b9697917dc2bf810d", size = 86607 }, - { url = "https://files.pythonhosted.org/packages/48/4e/89beaee3a4da0d1c6af1176d738cff415ff2ad3737785ee25382409fe3e3/yarl-1.15.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ac2bc069f4a458634c26b101c2341b18da85cb96afe0015990507efec2e417", size = 334077 }, - { url = "https://files.pythonhosted.org/packages/da/e8/8fcaa7552093f94c3f327783e2171da0eaa71db0c267510898a575066b0f/yarl-1.15.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd126498171f752dd85737ab1544329a4520c53eed3997f9b08aefbafb1cc53b", size = 347365 }, - { url = "https://files.pythonhosted.org/packages/be/fa/dc2002f82a89feab13a783d3e6b915a3a2e0e83314d9e3f6d845ee31bfcc/yarl-1.15.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db817b4e95eb05c362e3b45dafe7144b18603e1211f4a5b36eb9522ecc62bcf", size = 344823 }, - { url = "https://files.pythonhosted.org/packages/ae/c8/c4a00fe7f2aa6970c2651df332a14c88f8baaedb2e32d6c3b8c8a003ea74/yarl-1.15.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:076b1ed2ac819933895b1a000904f62d615fe4533a5cf3e052ff9a1da560575c", size = 337132 }, - { url = "https://files.pythonhosted.org/packages/07/bf/84125f85f44bf2af03f3cf64e87214b42cd59dcc8a04960d610a9825f4d4/yarl-1.15.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f8cfd847e6b9ecf9f2f2531c8427035f291ec286c0a4944b0a9fce58c6446046", size = 326258 }, - { url = "https://files.pythonhosted.org/packages/00/19/73ad8122b2fa73fe22e32c24b82a6c053cf6c73e2f649b73f7ef97bee8d0/yarl-1.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:32b66be100ac5739065496c74c4b7f3015cef792c3174982809274d7e51b3e04", size = 336212 }, - { url = "https://files.pythonhosted.org/packages/39/1d/2fa4337d11f6587e9b7565f84eba549f2921494bc8b10bfe811079acaa70/yarl-1.15.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34a2d76a1984cac04ff8b1bfc939ec9dc0914821264d4a9c8fd0ed6aa8d4cfd2", size = 330397 }, - { url = "https://files.pythonhosted.org/packages/39/ab/dce75e06806bcb4305966471ead03ce639d8230f4f52c32bd614d820c044/yarl-1.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0afad2cd484908f472c8fe2e8ef499facee54a0a6978be0e0cff67b1254fd747", size = 334985 }, - { url = "https://files.pythonhosted.org/packages/c1/98/3f679149347a5e34c952bf8f71a387bc96b3488fae81399a49f8b1a01134/yarl-1.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c68e820879ff39992c7f148113b46efcd6ec765a4865581f2902b3c43a5f4bbb", size = 356033 }, - { url = "https://files.pythonhosted.org/packages/f7/8c/96546061c19852d0a4b1b07084a58c2e8911db6bcf7838972cff542e09fb/yarl-1.15.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:98f68df80ec6ca3015186b2677c208c096d646ef37bbf8b49764ab4a38183931", size = 357710 }, - { url = "https://files.pythonhosted.org/packages/01/45/ade6fb3daf689816ebaddb3175c962731edf300425c3254c559b6d0dcc27/yarl-1.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c56ec1eacd0a5d35b8a29f468659c47f4fe61b2cab948ca756c39b7617f0aa5", size = 345532 }, - { url = "https://files.pythonhosted.org/packages/e7/d7/8de800d3aecda0e64c43e8fc844f7effc8731a6099fa0c055738a2247504/yarl-1.15.2-cp311-cp311-win32.whl", hash = "sha256:eedc3f247ee7b3808ea07205f3e7d7879bc19ad3e6222195cd5fbf9988853e4d", size = 78250 }, - { url = "https://files.pythonhosted.org/packages/3a/6c/69058bbcfb0164f221aa30e0cd1a250f6babb01221e27c95058c51c498ca/yarl-1.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:0ccaa1bc98751fbfcf53dc8dfdb90d96e98838010fc254180dd6707a6e8bb179", size = 84492 }, - { url = "https://files.pythonhosted.org/packages/e0/d1/17ff90e7e5b1a0b4ddad847f9ec6a214b87905e3a59d01bff9207ce2253b/yarl-1.15.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:82d5161e8cb8f36ec778fd7ac4d740415d84030f5b9ef8fe4da54784a1f46c94", size = 136721 }, - { url = "https://files.pythonhosted.org/packages/44/50/a64ca0577aeb9507f4b672f9c833d46cf8f1e042ce2e80c11753b936457d/yarl-1.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa2bea05ff0a8fb4d8124498e00e02398f06d23cdadd0fe027d84a3f7afde31e", size = 88954 }, - { url = "https://files.pythonhosted.org/packages/c9/0a/a30d0b02046d4088c1fd32d85d025bd70ceb55f441213dee14d503694f41/yarl-1.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99e12d2bf587b44deb74e0d6170fec37adb489964dbca656ec41a7cd8f2ff178", size = 86692 }, - { url = "https://files.pythonhosted.org/packages/06/0b/7613decb8baa26cba840d7ea2074bd3c5e27684cbcb6d06e7840d6c5226c/yarl-1.15.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:243fbbbf003754fe41b5bdf10ce1e7f80bcc70732b5b54222c124d6b4c2ab31c", size = 325762 }, - { url = "https://files.pythonhosted.org/packages/97/f5/b8c389a58d1eb08f89341fc1bbcc23a0341f7372185a0a0704dbdadba53a/yarl-1.15.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:856b7f1a7b98a8c31823285786bd566cf06226ac4f38b3ef462f593c608a9bd6", size = 335037 }, - { url = "https://files.pythonhosted.org/packages/cb/f9/d89b93a7bb8b66e01bf722dcc6fec15e11946e649e71414fd532b05c4d5d/yarl-1.15.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:553dad9af802a9ad1a6525e7528152a015b85fb8dbf764ebfc755c695f488367", size = 334221 }, - { url = "https://files.pythonhosted.org/packages/10/77/1db077601998e0831a540a690dcb0f450c31f64c492e993e2eaadfbc7d31/yarl-1.15.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30c3ff305f6e06650a761c4393666f77384f1cc6c5c0251965d6bfa5fbc88f7f", size = 330167 }, - { url = "https://files.pythonhosted.org/packages/3b/c2/e5b7121662fd758656784fffcff2e411c593ec46dc9ec68e0859a2ffaee3/yarl-1.15.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:353665775be69bbfc6d54c8d134bfc533e332149faeddd631b0bc79df0897f46", size = 317472 }, - { url = "https://files.pythonhosted.org/packages/c6/f3/41e366c17e50782651b192ba06a71d53500cc351547816bf1928fb043c4f/yarl-1.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f4fe99ce44128c71233d0d72152db31ca119711dfc5f2c82385ad611d8d7f897", size = 330896 }, - { url = "https://files.pythonhosted.org/packages/79/a2/d72e501bc1e33e68a5a31f584fe4556ab71a50a27bfd607d023f097cc9bb/yarl-1.15.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9c1e3ff4b89cdd2e1a24c214f141e848b9e0451f08d7d4963cb4108d4d798f1f", size = 328787 }, - { url = "https://files.pythonhosted.org/packages/9d/ba/890f7e1ea17f3c247748548eee876528ceb939e44566fa7d53baee57e5aa/yarl-1.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:711bdfae4e699a6d4f371137cbe9e740dc958530cb920eb6f43ff9551e17cfbc", size = 332631 }, - { url = "https://files.pythonhosted.org/packages/48/c7/27b34206fd5dfe76b2caa08bf22f9212b2d665d5bb2df8a6dd3af498dcf4/yarl-1.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4388c72174868884f76affcdd3656544c426407e0043c89b684d22fb265e04a5", size = 344023 }, - { url = "https://files.pythonhosted.org/packages/88/e7/730b130f4f02bd8b00479baf9a57fdea1dc927436ed1d6ba08fa5c36c68e/yarl-1.15.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:f0e1844ad47c7bd5d6fa784f1d4accc5f4168b48999303a868fe0f8597bde715", size = 352290 }, - { url = "https://files.pythonhosted.org/packages/84/9b/e8dda28f91a0af67098cddd455e6b540d3f682dda4c0de224215a57dee4a/yarl-1.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5cafb02cf097a82d74403f7e0b6b9df3ffbfe8edf9415ea816314711764a27b", size = 343742 }, - { url = "https://files.pythonhosted.org/packages/66/47/b1c6bb85f2b66decbe189e27fcc956ab74670a068655df30ef9a2e15c379/yarl-1.15.2-cp312-cp312-win32.whl", hash = "sha256:156ececdf636143f508770bf8a3a0498de64da5abd890c7dbb42ca9e3b6c05b8", size = 78051 }, - { url = "https://files.pythonhosted.org/packages/7d/9e/1a897e5248ec53e96e9f15b3e6928efd5e75d322c6cf666f55c1c063e5c9/yarl-1.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:435aca062444a7f0c884861d2e3ea79883bd1cd19d0a381928b69ae1b85bc51d", size = 84313 }, - { url = "https://files.pythonhosted.org/packages/46/ab/be3229898d7eb1149e6ba7fe44f873cf054d275a00b326f2a858c9ff7175/yarl-1.15.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:416f2e3beaeae81e2f7a45dc711258be5bdc79c940a9a270b266c0bec038fb84", size = 135006 }, - { url = "https://files.pythonhosted.org/packages/10/10/b91c186b1b0e63951f80481b3e6879bb9f7179d471fe7c4440c9e900e2a3/yarl-1.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:173563f3696124372831007e3d4b9821746964a95968628f7075d9231ac6bb33", size = 88121 }, - { url = "https://files.pythonhosted.org/packages/bf/1d/4ceaccf836b9591abfde775e84249b847ac4c6c14ee2dd8d15b5b3cede44/yarl-1.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ce2e0f6123a60bd1a7f5ae3b2c49b240c12c132847f17aa990b841a417598a2", size = 85967 }, - { url = "https://files.pythonhosted.org/packages/93/bd/c924f22bdb2c5d0ca03a9e64ecc5e041aace138c2a91afff7e2f01edc3a1/yarl-1.15.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eaea112aed589131f73d50d570a6864728bd7c0c66ef6c9154ed7b59f24da611", size = 325615 }, - { url = "https://files.pythonhosted.org/packages/59/a5/6226accd5c01cafd57af0d249c7cf9dd12569cd9c78fbd93e8198e7a9d84/yarl-1.15.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4ca3b9f370f218cc2a0309542cab8d0acdfd66667e7c37d04d617012485f904", size = 334945 }, - { url = "https://files.pythonhosted.org/packages/4c/c1/cc6ccdd2bcd0ff7291602d5831754595260f8d2754642dfd34fef1791059/yarl-1.15.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23ec1d3c31882b2a8a69c801ef58ebf7bae2553211ebbddf04235be275a38548", size = 336701 }, - { url = "https://files.pythonhosted.org/packages/ef/ff/39a767ee249444e4b26ea998a526838238f8994c8f274befc1f94dacfb43/yarl-1.15.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75119badf45f7183e10e348edff5a76a94dc19ba9287d94001ff05e81475967b", size = 330977 }, - { url = "https://files.pythonhosted.org/packages/dd/ba/b1fed73f9d39e3e7be8f6786be5a2ab4399c21504c9168c3cadf6e441c2e/yarl-1.15.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78e6fdc976ec966b99e4daa3812fac0274cc28cd2b24b0d92462e2e5ef90d368", size = 317402 }, - { url = "https://files.pythonhosted.org/packages/82/e8/03e3ebb7f558374f29c04868b20ca484d7997f80a0a191490790a8c28058/yarl-1.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8657d3f37f781d987037f9cc20bbc8b40425fa14380c87da0cb8dfce7c92d0fb", size = 331776 }, - { url = "https://files.pythonhosted.org/packages/1f/83/90b0f4fd1ecf2602ba4ac50ad0bbc463122208f52dd13f152bbc0d8417dd/yarl-1.15.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:93bed8a8084544c6efe8856c362af08a23e959340c87a95687fdbe9c9f280c8b", size = 331585 }, - { url = "https://files.pythonhosted.org/packages/c7/f6/1ed7e7f270ae5f9f1174c1f8597b29658f552fee101c26de8b2eb4ca147a/yarl-1.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:69d5856d526802cbda768d3e6246cd0d77450fa2a4bc2ea0ea14f0d972c2894b", size = 336395 }, - { url = "https://files.pythonhosted.org/packages/e0/3a/4354ed8812909d9ec54a92716a53259b09e6b664209231f2ec5e75f4820d/yarl-1.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ccad2800dfdff34392448c4bf834be124f10a5bc102f254521d931c1c53c455a", size = 342810 }, - { url = "https://files.pythonhosted.org/packages/de/cc/39e55e16b1415a87f6d300064965d6cfb2ac8571e11339ccb7dada2444d9/yarl-1.15.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a880372e2e5dbb9258a4e8ff43f13888039abb9dd6d515f28611c54361bc5644", size = 351441 }, - { url = "https://files.pythonhosted.org/packages/fb/19/5cd4757079dc9d9f3de3e3831719b695f709a8ce029e70b33350c9d082a7/yarl-1.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c998d0558805860503bc3a595994895ca0f7835e00668dadc673bbf7f5fbfcbe", size = 345875 }, - { url = "https://files.pythonhosted.org/packages/83/a0/ef09b54634f73417f1ea4a746456a4372c1b044f07b26e16fa241bd2d94e/yarl-1.15.2-cp313-cp313-win32.whl", hash = "sha256:533a28754e7f7439f217550a497bb026c54072dbe16402b183fdbca2431935a9", size = 302609 }, - { url = "https://files.pythonhosted.org/packages/20/9f/f39c37c17929d3975da84c737b96b606b68c495cc4ee86408f10523a1635/yarl-1.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:5838f2b79dc8f96fdc44077c9e4e2e33d7089b10788464609df788eb97d03aad", size = 308252 }, - { url = "https://files.pythonhosted.org/packages/7b/1f/544439ce6b7a498327d57ff40f0cd4f24bf4b1c1daf76c8c962dca022e71/yarl-1.15.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fbbb63bed5fcd70cd3dd23a087cd78e4675fb5a2963b8af53f945cbbca79ae16", size = 138555 }, - { url = "https://files.pythonhosted.org/packages/e8/b7/d6f33e7a42832f1e8476d0aabe089be0586a9110b5dfc2cef93444dc7c21/yarl-1.15.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2e93b88ecc8f74074012e18d679fb2e9c746f2a56f79cd5e2b1afcf2a8a786b", size = 89844 }, - { url = "https://files.pythonhosted.org/packages/93/34/ede8d8ed7350b4b21e33fc4eff71e08de31da697034969b41190132d421f/yarl-1.15.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af8ff8d7dc07ce873f643de6dfbcd45dc3db2c87462e5c387267197f59e6d776", size = 87671 }, - { url = "https://files.pythonhosted.org/packages/fa/51/6d71e92bc54b5788b18f3dc29806f9ce37e12b7c610e8073357717f34b78/yarl-1.15.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66f629632220a4e7858b58e4857927dd01a850a4cef2fb4044c8662787165cf7", size = 314558 }, - { url = "https://files.pythonhosted.org/packages/76/0a/f9ffe503b4ef77cd77c9eefd37717c092e26f2c2dbbdd45700f864831292/yarl-1.15.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:833547179c31f9bec39b49601d282d6f0ea1633620701288934c5f66d88c3e50", size = 327622 }, - { url = "https://files.pythonhosted.org/packages/8b/38/8eb602eeb153de0189d572dce4ed81b9b14f71de7c027d330b601b4fdcdc/yarl-1.15.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2aa738e0282be54eede1e3f36b81f1e46aee7ec7602aa563e81e0e8d7b67963f", size = 324447 }, - { url = "https://files.pythonhosted.org/packages/c2/1e/1c78c695a4c7b957b5665e46a89ea35df48511dbed301a05c0a8beed0cc3/yarl-1.15.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a13a07532e8e1c4a5a3afff0ca4553da23409fad65def1b71186fb867eeae8d", size = 319009 }, - { url = "https://files.pythonhosted.org/packages/06/a0/7ea93de4ca1991e7f92a8901dcd1585165f547d342f7c6f36f1ea58b75de/yarl-1.15.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c45817e3e6972109d1a2c65091504a537e257bc3c885b4e78a95baa96df6a3f8", size = 307760 }, - { url = "https://files.pythonhosted.org/packages/f4/b4/ceaa1f35cfb37fe06af3f7404438abf9a1262dc5df74dba37c90b0615e06/yarl-1.15.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:670eb11325ed3a6209339974b276811867defe52f4188fe18dc49855774fa9cf", size = 315038 }, - { url = "https://files.pythonhosted.org/packages/da/45/a2ca2b547c56550eefc39e45d61e4b42ae6dbb3e913810b5a0eb53e86412/yarl-1.15.2-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:d417a4f6943112fae3924bae2af7112562285848d9bcee737fc4ff7cbd450e6c", size = 312898 }, - { url = "https://files.pythonhosted.org/packages/ea/e0/f692ba36dedc5b0b22084bba558a7ede053841e247b7dd2adbb9d40450be/yarl-1.15.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:bc8936d06cd53fddd4892677d65e98af514c8d78c79864f418bbf78a4a2edde4", size = 319370 }, - { url = "https://files.pythonhosted.org/packages/b1/3f/0e382caf39958be6ae61d4bb0c82a68a3c45a494fc8cdc6f55c29757970e/yarl-1.15.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:954dde77c404084c2544e572f342aef384240b3e434e06cecc71597e95fd1ce7", size = 332429 }, - { url = "https://files.pythonhosted.org/packages/21/6b/c824a4a1c45d67b15b431d4ab83b63462bfcbc710065902e10fa5c2ffd9e/yarl-1.15.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:5bc0df728e4def5e15a754521e8882ba5a5121bd6b5a3a0ff7efda5d6558ab3d", size = 333143 }, - { url = "https://files.pythonhosted.org/packages/20/76/8af2a1d93fe95b04e284b5d55daaad33aae6e2f6254a1bcdb40e2752af6c/yarl-1.15.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b71862a652f50babab4a43a487f157d26b464b1dedbcc0afda02fd64f3809d04", size = 326687 }, - { url = "https://files.pythonhosted.org/packages/1c/53/490830773f907ef8a311cc5d82e5830f75f7692c1adacbdb731d3f1246fd/yarl-1.15.2-cp38-cp38-win32.whl", hash = "sha256:63eab904f8630aed5a68f2d0aeab565dcfc595dc1bf0b91b71d9ddd43dea3aea", size = 78705 }, - { url = "https://files.pythonhosted.org/packages/9c/9d/d944e897abf37f50f4fa2d8d6f5fd0ed9413bc8327d3b4cc25ba9694e1ba/yarl-1.15.2-cp38-cp38-win_amd64.whl", hash = "sha256:2cf441c4b6e538ba0d2591574f95d3fdd33f1efafa864faa077d9636ecc0c4e9", size = 84998 }, - { url = "https://files.pythonhosted.org/packages/91/1c/1c9d08c29b10499348eedc038cf61b6d96d5ba0e0d69438975845939ed3c/yarl-1.15.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a32d58f4b521bb98b2c0aa9da407f8bd57ca81f34362bcb090e4a79e9924fefc", size = 138011 }, - { url = "https://files.pythonhosted.org/packages/d4/33/2d4a1418bae6d7883c1fcc493be7b6d6fe015919835adc9e8eeba472e9f7/yarl-1.15.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:766dcc00b943c089349d4060b935c76281f6be225e39994c2ccec3a2a36ad627", size = 89618 }, - { url = "https://files.pythonhosted.org/packages/78/2e/0024c674a376cfdc722a167a8f308f5779aca615cb7a28d67fbeabf3f697/yarl-1.15.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bed1b5dbf90bad3bfc19439258c97873eab453c71d8b6869c136346acfe497e7", size = 87347 }, - { url = "https://files.pythonhosted.org/packages/c5/08/a01874dabd4ddf475c5c2adc86f7ac329f83a361ee513a97841720ab7b24/yarl-1.15.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed20a4bdc635f36cb19e630bfc644181dd075839b6fc84cac51c0f381ac472e2", size = 310438 }, - { url = "https://files.pythonhosted.org/packages/09/95/691bc6de2c1b0e9c8bbaa5f8f38118d16896ba1a069a09d1fb073d41a093/yarl-1.15.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d538df442c0d9665664ab6dd5fccd0110fa3b364914f9c85b3ef9b7b2e157980", size = 325384 }, - { url = "https://files.pythonhosted.org/packages/95/fd/fee11eb3337f48c62d39c5676e6a0e4e318e318900a901b609a3c45394df/yarl-1.15.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c6cf1d92edf936ceedc7afa61b07e9d78a27b15244aa46bbcd534c7458ee1b", size = 321820 }, - { url = "https://files.pythonhosted.org/packages/7a/ad/4a2c9bbebaefdce4a69899132f4bf086abbddb738dc6e794a31193bc0854/yarl-1.15.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce44217ad99ffad8027d2fde0269ae368c86db66ea0571c62a000798d69401fb", size = 314150 }, - { url = "https://files.pythonhosted.org/packages/38/7d/552c37bc6c4ae8ea900e44b6c05cb16d50dca72d3782ccd66f53e27e353f/yarl-1.15.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47a6000a7e833ebfe5886b56a31cb2ff12120b1efd4578a6fcc38df16cc77bd", size = 304202 }, - { url = "https://files.pythonhosted.org/packages/2e/f8/c22a158f3337f49775775ecef43fc097a98b20cdce37425b68b9c45a6f94/yarl-1.15.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e52f77a0cd246086afde8815039f3e16f8d2be51786c0a39b57104c563c5cbb0", size = 310311 }, - { url = "https://files.pythonhosted.org/packages/ce/e4/ebce06afa25c2a6c8e6c9a5915cbbc7940a37f3ec38e950e8f346ca908da/yarl-1.15.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:f9ca0e6ce7774dc7830dc0cc4bb6b3eec769db667f230e7c770a628c1aa5681b", size = 310645 }, - { url = "https://files.pythonhosted.org/packages/0a/34/5504cc8fbd1be959ec0a1e9e9f471fd438c37cb877b0178ce09085b36b51/yarl-1.15.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:136f9db0f53c0206db38b8cd0c985c78ded5fd596c9a86ce5c0b92afb91c3a19", size = 313328 }, - { url = "https://files.pythonhosted.org/packages/cf/e4/fb3f91a539c6505e347d7d75bc675d291228960ffd6481ced76a15412924/yarl-1.15.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:173866d9f7409c0fb514cf6e78952e65816600cb888c68b37b41147349fe0057", size = 330135 }, - { url = "https://files.pythonhosted.org/packages/e1/08/a0b27db813f0159e1c8a45f48852afded501de2f527e7613c4dcf436ecf7/yarl-1.15.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:6e840553c9c494a35e449a987ca2c4f8372668ee954a03a9a9685075228e5036", size = 327155 }, - { url = "https://files.pythonhosted.org/packages/97/4e/b3414dded12d0e2b52eb1964c21a8d8b68495b320004807de770f7b6b53a/yarl-1.15.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:458c0c65802d816a6b955cf3603186de79e8fdb46d4f19abaec4ef0a906f50a7", size = 320810 }, - { url = "https://files.pythonhosted.org/packages/bb/ca/e5149c55d1c9dcf3d5b48acd7c71ca8622fd2f61322d0386fe63ba106774/yarl-1.15.2-cp39-cp39-win32.whl", hash = "sha256:5b48388ded01f6f2429a8c55012bdbd1c2a0c3735b3e73e221649e524c34a58d", size = 78686 }, - { url = "https://files.pythonhosted.org/packages/b1/87/f56a80a1abaf65dbf138b821357b51b6cc061756bb7d93f08797950b3881/yarl-1.15.2-cp39-cp39-win_amd64.whl", hash = "sha256:81dadafb3aa124f86dc267a2168f71bbd2bfb163663661ab0038f6e4b8edb810", size = 84818 }, - { url = "https://files.pythonhosted.org/packages/46/cf/a28c494decc9c8776b0d7b729c68d26fdafefcedd8d2eab5d9cd767376b2/yarl-1.15.2-py3-none-any.whl", hash = "sha256:0d3105efab7c5c091609abacad33afff33bdff0035bece164c98bcf5a85ef90a", size = 38891 }, -] - -[[package]] -name = "yarl" -version = "1.22.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.10'", - "python_full_version == '3.9.*'", -] -dependencies = [ - { name = "idna", marker = "python_full_version >= '3.9'" }, - { name = "multidict", version = "6.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, - { name = "propcache", version = "0.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.9'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/43/a2204825342f37c337f5edb6637040fa14e365b2fcc2346960201d457579/yarl-1.22.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c7bd6683587567e5a49ee6e336e0612bec8329be1b7d4c8af5687dcdeb67ee1e", size = 140517 }, - { url = "https://files.pythonhosted.org/packages/44/6f/674f3e6f02266428c56f704cd2501c22f78e8b2eeb23f153117cc86fb28a/yarl-1.22.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5cdac20da754f3a723cceea5b3448e1a2074866406adeb4ef35b469d089adb8f", size = 93495 }, - { url = "https://files.pythonhosted.org/packages/b8/12/5b274d8a0f30c07b91b2f02cba69152600b47830fcfb465c108880fcee9c/yarl-1.22.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:07a524d84df0c10f41e3ee918846e1974aba4ec017f990dc735aad487a0bdfdf", size = 94400 }, - { url = "https://files.pythonhosted.org/packages/e2/7f/df1b6949b1fa1aa9ff6de6e2631876ad4b73c4437822026e85d8acb56bb1/yarl-1.22.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1b329cb8146d7b736677a2440e422eadd775d1806a81db2d4cded80a48efc1a", size = 347545 }, - { url = "https://files.pythonhosted.org/packages/84/09/f92ed93bd6cd77872ab6c3462df45ca45cd058d8f1d0c9b4f54c1704429f/yarl-1.22.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75976c6945d85dbb9ee6308cd7ff7b1fb9409380c82d6119bd778d8fcfe2931c", size = 319598 }, - { url = "https://files.pythonhosted.org/packages/c3/97/ac3f3feae7d522cf7ccec3d340bb0b2b61c56cb9767923df62a135092c6b/yarl-1.22.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:80ddf7a5f8c86cb3eb4bc9028b07bbbf1f08a96c5c0bc1244be5e8fefcb94147", size = 363893 }, - { url = "https://files.pythonhosted.org/packages/06/49/f3219097403b9c84a4d079b1d7bda62dd9b86d0d6e4428c02d46ab2c77fc/yarl-1.22.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d332fc2e3c94dad927f2112395772a4e4fedbcf8f80efc21ed7cdfae4d574fdb", size = 371240 }, - { url = "https://files.pythonhosted.org/packages/35/9f/06b765d45c0e44e8ecf0fe15c9eacbbde342bb5b7561c46944f107bfb6c3/yarl-1.22.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cf71bf877efeac18b38d3930594c0948c82b64547c1cf420ba48722fe5509f6", size = 346965 }, - { url = "https://files.pythonhosted.org/packages/c5/69/599e7cea8d0fcb1694323b0db0dda317fa3162f7b90166faddecf532166f/yarl-1.22.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:663e1cadaddae26be034a6ab6072449a8426ddb03d500f43daf952b74553bba0", size = 342026 }, - { url = "https://files.pythonhosted.org/packages/95/6f/9dfd12c8bc90fea9eab39832ee32ea48f8e53d1256252a77b710c065c89f/yarl-1.22.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6dcbb0829c671f305be48a7227918cfcd11276c2d637a8033a99a02b67bf9eda", size = 335637 }, - { url = "https://files.pythonhosted.org/packages/57/2e/34c5b4eb9b07e16e873db5b182c71e5f06f9b5af388cdaa97736d79dd9a6/yarl-1.22.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f0d97c18dfd9a9af4490631905a3f131a8e4c9e80a39353919e2cfed8f00aedc", size = 359082 }, - { url = "https://files.pythonhosted.org/packages/31/71/fa7e10fb772d273aa1f096ecb8ab8594117822f683bab7d2c5a89914c92a/yarl-1.22.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:437840083abe022c978470b942ff832c3940b2ad3734d424b7eaffcd07f76737", size = 357811 }, - { url = "https://files.pythonhosted.org/packages/26/da/11374c04e8e1184a6a03cf9c8f5688d3e5cec83ed6f31ad3481b3207f709/yarl-1.22.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a899cbd98dce6f5d8de1aad31cb712ec0a530abc0a86bd6edaa47c1090138467", size = 351223 }, - { url = "https://files.pythonhosted.org/packages/82/8f/e2d01f161b0c034a30410e375e191a5d27608c1f8693bab1a08b089ca096/yarl-1.22.0-cp310-cp310-win32.whl", hash = "sha256:595697f68bd1f0c1c159fcb97b661fc9c3f5db46498043555d04805430e79bea", size = 82118 }, - { url = "https://files.pythonhosted.org/packages/62/46/94c76196642dbeae634c7a61ba3da88cd77bed875bf6e4a8bed037505aa6/yarl-1.22.0-cp310-cp310-win_amd64.whl", hash = "sha256:cb95a9b1adaa48e41815a55ae740cfda005758104049a640a398120bf02515ca", size = 86852 }, - { url = "https://files.pythonhosted.org/packages/af/af/7df4f179d3b1a6dcb9a4bd2ffbc67642746fcafdb62580e66876ce83fff4/yarl-1.22.0-cp310-cp310-win_arm64.whl", hash = "sha256:b85b982afde6df99ecc996990d4ad7ccbdbb70e2a4ba4de0aecde5922ba98a0b", size = 82012 }, - { url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607 }, - { url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027 }, - { url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963 }, - { url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406 }, - { url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581 }, - { url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924 }, - { url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890 }, - { url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819 }, - { url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601 }, - { url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072 }, - { url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311 }, - { url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094 }, - { url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944 }, - { url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804 }, - { url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858 }, - { url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637 }, - { url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000 }, - { url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338 }, - { url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909 }, - { url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940 }, - { url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825 }, - { url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705 }, - { url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518 }, - { url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267 }, - { url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797 }, - { url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535 }, - { url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324 }, - { url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803 }, - { url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220 }, - { url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589 }, - { url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213 }, - { url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330 }, - { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980 }, - { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424 }, - { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821 }, - { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243 }, - { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361 }, - { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036 }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671 }, - { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059 }, - { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356 }, - { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331 }, - { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590 }, - { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316 }, - { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431 }, - { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555 }, - { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965 }, - { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205 }, - { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209 }, - { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966 }, - { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312 }, - { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967 }, - { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949 }, - { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818 }, - { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626 }, - { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129 }, - { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776 }, - { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879 }, - { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996 }, - { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047 }, - { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947 }, - { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943 }, - { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715 }, - { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857 }, - { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520 }, - { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504 }, - { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282 }, - { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080 }, - { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696 }, - { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121 }, - { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080 }, - { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661 }, - { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645 }, - { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361 }, - { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451 }, - { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814 }, - { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799 }, - { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990 }, - { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292 }, - { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888 }, - { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223 }, - { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981 }, - { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303 }, - { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820 }, - { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203 }, - { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173 }, - { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562 }, - { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828 }, - { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551 }, - { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512 }, - { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400 }, - { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140 }, - { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473 }, - { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056 }, - { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292 }, - { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171 }, - { url = "https://files.pythonhosted.org/packages/94/fd/6480106702a79bcceda5fd9c63cb19a04a6506bd5ce7fd8d9b63742f0021/yarl-1.22.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3aa27acb6de7a23785d81557577491f6c38a5209a254d1191519d07d8fe51748", size = 141301 }, - { url = "https://files.pythonhosted.org/packages/42/e1/6d95d21b17a93e793e4ec420a925fe1f6a9342338ca7a563ed21129c0990/yarl-1.22.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:af74f05666a5e531289cb1cc9c883d1de2088b8e5b4de48004e5ca8a830ac859", size = 93864 }, - { url = "https://files.pythonhosted.org/packages/32/58/b8055273c203968e89808413ea4c984988b6649baabf10f4522e67c22d2f/yarl-1.22.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:62441e55958977b8167b2709c164c91a6363e25da322d87ae6dd9c6019ceecf9", size = 94706 }, - { url = "https://files.pythonhosted.org/packages/18/91/d7bfbc28a88c2895ecd0da6a874def0c147de78afc52c773c28e1aa233a3/yarl-1.22.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b580e71cac3f8113d3135888770903eaf2f507e9421e5697d6ee6d8cd1c7f054", size = 347100 }, - { url = "https://files.pythonhosted.org/packages/bd/e8/37a1e7b99721c0564b1fc7b0a4d1f595ef6fb8060d82ca61775b644185f7/yarl-1.22.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e81fda2fb4a07eda1a2252b216aa0df23ebcd4d584894e9612e80999a78fd95b", size = 318902 }, - { url = "https://files.pythonhosted.org/packages/1c/ef/34724449d7ef2db4f22df644f2dac0b8a275d20f585e526937b3ae47b02d/yarl-1.22.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99b6fc1d55782461b78221e95fc357b47ad98b041e8e20f47c1411d0aacddc60", size = 363302 }, - { url = "https://files.pythonhosted.org/packages/8a/04/88a39a5dad39889f192cce8d66cc4c58dbeca983e83f9b6bf23822a7ed91/yarl-1.22.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:088e4e08f033db4be2ccd1f34cf29fe994772fb54cfe004bbf54db320af56890", size = 370816 }, - { url = "https://files.pythonhosted.org/packages/6b/1f/5e895e547129413f56c76be2c3ce4b96c797d2d0ff3e16a817d9269b12e6/yarl-1.22.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4e1f6f0b4da23e61188676e3ed027ef0baa833a2e633c29ff8530800edccba", size = 346465 }, - { url = "https://files.pythonhosted.org/packages/11/13/a750e9fd6f9cc9ed3a52a70fe58ffe505322f0efe0d48e1fd9ffe53281f5/yarl-1.22.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:84fc3ec96fce86ce5aa305eb4aa9358279d1aa644b71fab7b8ed33fe3ba1a7ca", size = 341506 }, - { url = "https://files.pythonhosted.org/packages/3c/67/bb6024de76e7186611ebe626aec5b71a2d2ecf9453e795f2dbd80614784c/yarl-1.22.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5dbeefd6ca588b33576a01b0ad58aa934bc1b41ef89dee505bf2932b22ddffba", size = 335030 }, - { url = "https://files.pythonhosted.org/packages/a2/be/50b38447fd94a7992996a62b8b463d0579323fcfc08c61bdba949eef8a5d/yarl-1.22.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14291620375b1060613f4aab9ebf21850058b6b1b438f386cc814813d901c60b", size = 358560 }, - { url = "https://files.pythonhosted.org/packages/e2/89/c020b6f547578c4e3dbb6335bf918f26e2f34ad0d1e515d72fd33ac0c635/yarl-1.22.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:a4fcfc8eb2c34148c118dfa02e6427ca278bfd0f3df7c5f99e33d2c0e81eae3e", size = 357290 }, - { url = "https://files.pythonhosted.org/packages/8c/52/c49a619ee35a402fa3a7019a4fa8d26878fec0d1243f6968bbf516789578/yarl-1.22.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:029866bde8d7b0878b9c160e72305bbf0a7342bcd20b9999381704ae03308dc8", size = 350700 }, - { url = "https://files.pythonhosted.org/packages/ab/c9/f5042d87777bf6968435f04a2bbb15466b2f142e6e47fa4f34d1a3f32f0c/yarl-1.22.0-cp39-cp39-win32.whl", hash = "sha256:4dcc74149ccc8bba31ce1944acee24813e93cfdee2acda3c172df844948ddf7b", size = 82323 }, - { url = "https://files.pythonhosted.org/packages/fd/58/d00f7cad9eba20c4eefac2682f34661d1d1b3a942fc0092eb60e78cfb733/yarl-1.22.0-cp39-cp39-win_amd64.whl", hash = "sha256:10619d9fdee46d20edc49d3479e2f8269d0779f1b031e6f7c2aa1c76be04b7ed", size = 87145 }, - { url = "https://files.pythonhosted.org/packages/c2/a3/70904f365080780d38b919edd42d224b8c4ce224a86950d2eaa2a24366ad/yarl-1.22.0-cp39-cp39-win_arm64.whl", hash = "sha256:dd7afd3f8b0bfb4e0d9fc3c31bfe8a4ec7debe124cfd90619305def3c8ca8cd2", size = 82173 }, - { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814 }, -] diff --git a/third_party/agfs/agfs-shell/webapp/.gitignore b/third_party/agfs/agfs-shell/webapp/.gitignore deleted file mode 100644 index ae1943226..000000000 --- a/third_party/agfs/agfs-shell/webapp/.gitignore +++ /dev/null @@ -1,119 +0,0 @@ -# Dependencies -node_modules -package-lock.json -yarn.lock -pnpm-lock.yaml - -# Build output -dist -dist-ssr -build -*.local - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -!.vscode/settings.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -# Environment variables -.env -.env.local -.env.development.local -.env.test.local -.env.production.local - -# Testing -coverage -*.lcov -.nyc_output - -# Cache -.cache -.parcel-cache -.eslintcache -.stylelintcache - -# Temporary files -*.tmp -*.temp -.tmp -.temp - -# OS files -Thumbs.db -Desktop.ini -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# TypeScript -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# Debug files -*.cpuprofile -*.heapsnapshot - -# Vite -vite.config.js.timestamp-* -vite.config.ts.timestamp-* diff --git a/third_party/agfs/agfs-shell/webapp/index.html b/third_party/agfs/agfs-shell/webapp/index.html deleted file mode 100644 index e2828c311..000000000 --- a/third_party/agfs/agfs-shell/webapp/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - AGFS Shell - - -
- - - diff --git a/third_party/agfs/agfs-shell/webapp/package.json b/third_party/agfs/agfs-shell/webapp/package.json deleted file mode 100644 index 48a62e2d8..000000000 --- a/third_party/agfs/agfs-shell/webapp/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "agfs-shell-webapp", - "version": "1.0.0", - "private": true, - "dependencies": { - "react": "^18.2.0", - "react-dom": "^18.2.0", - "@monaco-editor/react": "^4.6.0", - "@xterm/xterm": "^5.3.0", - "@xterm/addon-fit": "^0.10.0", - "react-split": "^2.0.14" - }, - "devDependencies": { - "@vitejs/plugin-react": "^4.2.1", - "vite": "^5.0.0" - }, - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - } -} diff --git a/third_party/agfs/agfs-shell/webapp/public/logo.png b/third_party/agfs/agfs-shell/webapp/public/logo.png deleted file mode 100644 index 3810866da..000000000 Binary files a/third_party/agfs/agfs-shell/webapp/public/logo.png and /dev/null differ diff --git a/third_party/agfs/agfs-shell/webapp/setup.sh b/third_party/agfs/agfs-shell/webapp/setup.sh deleted file mode 100755 index 76beb5642..000000000 --- a/third_party/agfs/agfs-shell/webapp/setup.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/bin/bash - -# AGFS Shell WebApp Setup Script - -set -e - -echo "🚀 Setting up AGFS Shell WebApp..." - -# Check if uv is installed -if ! command -v uv &> /dev/null; then - echo "❌ Error: uv is not installed" - echo "Please install uv first: https://github.com/astral-sh/uv" - exit 1 -fi - -# Check if npm is installed -if ! command -v npm &> /dev/null; then - echo "❌ Error: npm is not installed" - echo "Please install Node.js and npm first" - exit 1 -fi - -# Install Python dependencies -echo "📦 Installing Python dependencies..." -cd "$(dirname "$0")/.." -uv sync --extra webapp - -# Install frontend dependencies -echo "📦 Installing frontend dependencies..." -cd webapp -npm install - -# Build frontend -echo "🔨 Building frontend..." -npm run build - -echo "✅ Setup complete!" -echo "" -echo "To start the web app, run:" -echo " agfs-shell --webapp" -echo "" -echo "Or with custom host/port:" -echo " agfs-shell --webapp --webapp-host 0.0.0.0 --webapp-port 8000" diff --git a/third_party/agfs/agfs-shell/webapp/src/App.css b/third_party/agfs/agfs-shell/webapp/src/App.css deleted file mode 100644 index c877c0239..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/App.css +++ /dev/null @@ -1,446 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overflow: hidden; -} - -#root { - width: 100vw; - height: 100vh; - overflow: hidden; -} - -.app { - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - background-color: #1e1e1e; - color: #cccccc; -} - -.app-body { - flex: 1; - display: flex; - overflow: hidden; -} - -.sidebar { - min-width: 150px; - background-color: #252526; - border-right: 1px solid #3e3e42; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.sidebar-header { - padding: 12px 16px; - background-color: #2d2d30; - border-bottom: 1px solid #3e3e42; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - color: #cccccc; - font-weight: 600; -} - -.main-content { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.editor-container { - background-color: #1e1e1e; - overflow: hidden; - position: relative; -} - -.editor-tabs { - display: flex; - background-color: #2d2d30; - border-bottom: 1px solid #3e3e42; - height: 35px; -} - -.editor-tab { - padding: 8px 16px; - background-color: #2d2d30; - color: #969696; - border-right: 1px solid #3e3e42; - cursor: pointer; - font-size: 13px; - display: flex; - align-items: center; - gap: 8px; -} - -.editor-tab.active { - background-color: #1e1e1e; - color: #ffffff; -} - -.editor-tab:hover { - background-color: #2a2a2a; -} - -.editor-wrapper { - height: calc(100% - 35px); - width: 100%; -} - -.terminal-container { - background-color: #1e1e1e; - border-top: 1px solid #3e3e42; - display: flex; - flex-direction: column; - min-height: 100px; -} - -.terminal-header { - display: flex; - background-color: #2d2d30; - border-bottom: 1px solid #3e3e42; - height: 35px; - align-items: center; - padding: 0 16px; - font-size: 13px; -} - -.terminal-wrapper { - flex: 1; - padding: 8px; - overflow: hidden; -} - -/* File tree styles */ -.file-tree { - flex: 1; - overflow-y: auto; - overflow-x: hidden; - padding: 4px 0; -} - -.file-tree-item { - padding: 4px 8px; - padding-left: calc(8px + var(--depth) * 16px); - cursor: pointer; - font-size: 13px; - display: flex; - align-items: center; - gap: 6px; - user-select: none; - white-space: nowrap; -} - -.file-tree-item:hover { - background-color: #2a2d2e; -} - -.file-tree-item.selected { - background-color: #37373d; -} - -.file-tree-item.directory { - font-weight: 500; -} - -.file-icon { - font-size: 14px; - flex-shrink: 0; -} - -.expand-icon { - font-size: 12px; - flex-shrink: 0; - width: 16px; - text-align: center; - transition: transform 0.2s; -} - -.expand-icon.expanded { - transform: rotate(90deg); -} - -.expand-icon-placeholder { - flex-shrink: 0; - width: 16px; - display: inline-block; -} - -/* Scrollbar styles */ -::-webkit-scrollbar { - width: 10px; - height: 10px; -} - -::-webkit-scrollbar-track { - background: #1e1e1e; -} - -::-webkit-scrollbar-thumb { - background: #424242; - border-radius: 5px; -} - -::-webkit-scrollbar-thumb:hover { - background: #4e4e4e; -} - -/* Loading state */ -.loading { - display: flex; - align-items: center; - justify-content: center; - height: 100%; - color: #969696; -} - -/* Menu bar styles */ -.menu-bar { - height: 35px; - background-color: #2d2d30; - border-bottom: 1px solid #3e3e42; - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 8px; - flex-shrink: 0; -} - -.menu-left { - display: flex; - align-items: center; - gap: 8px; -} - -.menu-logo { - display: flex; - align-items: center; -} - -.menu-logo img { - height: 24px; - width: auto; - filter: invert(1) brightness(0.95); -} - -.menu-items { - display: flex; - gap: 4px; -} - -.menu-info { - display: flex; - gap: 16px; - align-items: center; - font-size: 12px; - color: #969696; -} - -.menu-info-item { - display: flex; - align-items: center; - gap: 4px; -} - -.menu-item { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - cursor: pointer; - font-size: 13px; - border-radius: 4px; - transition: background-color 0.15s; - user-select: none; -} - -.menu-item:hover:not(.disabled) { - background-color: #37373d; -} - -.menu-item.disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.menu-icon { - font-size: 14px; -} - -.menu-shortcut { - margin-left: 8px; - font-size: 11px; - color: #969696; -} - -/* Dialog styles */ -.dialog-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.6); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.dialog { - background-color: #2d2d30; - border: 1px solid #3e3e42; - border-radius: 6px; - min-width: 400px; - max-width: 600px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); -} - -.dialog-header { - padding: 16px 20px; - border-bottom: 1px solid #3e3e42; - font-size: 14px; - font-weight: 600; -} - -.dialog-body { - padding: 20px; -} - -.dialog-body label { - display: block; - margin-bottom: 8px; - font-size: 13px; - color: #cccccc; -} - -.dialog-body input { - width: 100%; - padding: 8px 12px; - background-color: #1e1e1e; - border: 1px solid #3e3e42; - border-radius: 4px; - color: #cccccc; - font-size: 13px; - font-family: 'Consolas', 'Monaco', monospace; -} - -.dialog-body input:focus { - outline: none; - border-color: #007acc; -} - -.dialog-footer { - padding: 16px 20px; - border-top: 1px solid #3e3e42; - display: flex; - justify-content: flex-end; - gap: 8px; -} - -.button { - padding: 6px 16px; - border-radius: 4px; - font-size: 13px; - cursor: pointer; - border: none; - transition: background-color 0.15s; -} - -.button-primary { - background-color: #007acc; - color: #ffffff; -} - -.button-primary:hover { - background-color: #0098ff; -} - -.button-secondary { - background-color: #3e3e42; - color: #cccccc; -} - -.button-secondary:hover { - background-color: #4e4e52; -} - -/* Context menu styles */ -.context-menu { - position: fixed; - background-color: #2d2d30; - border: 1px solid #3e3e42; - border-radius: 4px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); - min-width: 160px; - z-index: 2000; - padding: 4px 0; -} - -.context-menu-item { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - cursor: pointer; - font-size: 13px; - color: #cccccc; - user-select: none; -} - -.context-menu-item:hover:not(.disabled) { - background-color: #37373d; -} - -.context-menu-item.disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.context-menu-icon { - font-size: 14px; - width: 16px; - text-align: center; -} - -.context-menu-separator { - height: 1px; - background-color: #3e3e42; - margin: 4px 0; -} - -/* Resizer styles */ -.resizer { - background-color: #3e3e42; - position: relative; - z-index: 10; -} - -.resizer:hover { - background-color: #007acc; -} - -.resizer-vertical { - width: 4px; - cursor: col-resize; - flex-shrink: 0; -} - -.resizer-horizontal { - height: 4px; - cursor: row-resize; - flex-shrink: 0; -} diff --git a/third_party/agfs/agfs-shell/webapp/src/App.jsx b/third_party/agfs/agfs-shell/webapp/src/App.jsx deleted file mode 100644 index 04db6c960..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/App.jsx +++ /dev/null @@ -1,327 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import FileTree from './components/FileTree'; -import Editor from './components/Editor'; -import Terminal from './components/Terminal'; -import MenuBar from './components/MenuBar'; -import './App.css'; - -function App() { - const [selectedFile, setSelectedFile] = useState(null); - const [fileContent, setFileContent] = useState(''); - const [savedContent, setSavedContent] = useState(''); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const [currentPath, setCurrentPath] = useState('/'); - const [currentDirectory, setCurrentDirectory] = useState('/'); - const [sidebarWidth, setSidebarWidth] = useState(250); - const [terminalHeight, setTerminalHeight] = useState(250); - const [refreshTrigger, setRefreshTrigger] = useState(0); - const [showNewFileDialog, setShowNewFileDialog] = useState(false); - const wsRef = useRef(null); - const editorRef = useRef(null); - const fileInputRef = useRef(null); - const isResizingSidebar = useRef(false); - const isResizingTerminal = useRef(false); - - // Check if file is a text file based on extension - const isTextFile = (filename) => { - const textExtensions = [ - 'txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'jsx', 'ts', 'tsx', - 'py', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'php', 'rb', 'go', 'rs', - 'sh', 'bash', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf', - 'sql', 'log', 'csv', 'tsv', 'svg', 'vue', 'scss', 'sass', 'less', - 'gitignore', 'dockerfile', 'makefile', 'readme' - ]; - - const ext = filename.split('.').pop().toLowerCase(); - return textExtensions.includes(ext) || !filename.includes('.'); - }; - - const handleFileSelect = async (file) => { - // Update current directory based on selected item - if (file.type === 'directory') { - setCurrentDirectory(file.path); - } else { - // For files, set current directory to parent directory - const parentDir = file.path.substring(0, file.path.lastIndexOf('/')) || '/'; - setCurrentDirectory(parentDir); - } - - if (file.type === 'file') { - // Check if it's a text file - if (!isTextFile(file.name)) { - // Non-text file, trigger download - const downloadUrl = `/api/files/download?path=${encodeURIComponent(file.path)}`; - const link = document.createElement('a'); - link.href = downloadUrl; - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - return; - } - - // Text file, display in editor - setSelectedFile(file); - // Fetch file content from API - try { - const response = await fetch(`/api/files/read?path=${encodeURIComponent(file.path)}`); - const data = await response.json(); - const content = data.content || ''; - setFileContent(content); - setSavedContent(content); - setHasUnsavedChanges(false); - } catch (error) { - console.error('Error reading file:', error); - setFileContent(''); - setSavedContent(''); - setHasUnsavedChanges(false); - } - } - }; - - const handleContentChange = (content) => { - setFileContent(content); - setHasUnsavedChanges(content !== savedContent); - }; - - const handleFileSave = async (content) => { - if (!selectedFile) return; - - try { - const response = await fetch('/api/files/write', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - path: selectedFile.path, - content: content, - }), - }); - - if (response.ok) { - // Update saved content and reset unsaved changes flag - setSavedContent(content); - setHasUnsavedChanges(false); - } else { - console.error('Error saving file:', await response.text()); - } - } catch (error) { - console.error('Error saving file:', error); - } - }; - - const handleNewFile = async (filePath) => { - try { - // Create empty file - await fetch('/api/files/write', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - path: filePath, - content: '', - }), - }); - - // Select the newly created file - const fileName = filePath.split('/').pop(); - setSelectedFile({ - name: fileName, - path: filePath, - type: 'file' - }); - setFileContent(''); - setSavedContent(''); - setHasUnsavedChanges(false); - - // Trigger file tree refresh - setRefreshTrigger(prev => prev + 1); - } catch (error) { - console.error('Error creating file:', error); - alert('Failed to create file: ' + error.message); - } - }; - - const handleMenuSave = () => { - if (editorRef.current) { - editorRef.current.save(); - } - }; - - const handleUpload = async (files) => { - if (!files || files.length === 0) return; - - let successCount = 0; - let failCount = 0; - - for (const file of files) { - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('directory', currentDirectory); - - const response = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }); - - if (!response.ok) { - const data = await response.json(); - alert(`Failed to upload ${file.name}: ${data.error}`); - failCount++; - } else { - successCount++; - } - } catch (error) { - alert(`Failed to upload ${file.name}: ${error.message}`); - failCount++; - } - } - - // Trigger a refresh of the file tree - if (successCount > 0) { - setRefreshTrigger(prev => prev + 1); - } - - alert(`Uploaded ${successCount} file(s) to ${currentDirectory}${failCount > 0 ? ` (${failCount} failed)` : ''}`); - }; - - // Handle sidebar resize - const handleSidebarMouseDown = (e) => { - isResizingSidebar.current = true; - e.preventDefault(); - }; - - const handleMouseMove = (e) => { - if (isResizingSidebar.current) { - const newWidth = e.clientX; - if (newWidth >= 150 && newWidth <= 600) { - setSidebarWidth(newWidth); - } - } - if (isResizingTerminal.current) { - const newHeight = window.innerHeight - e.clientY; - if (newHeight >= 100 && newHeight <= window.innerHeight - 200) { - setTerminalHeight(newHeight); - } - } - }; - - const handleMouseUp = () => { - isResizingSidebar.current = false; - isResizingTerminal.current = false; - }; - - // Handle terminal resize - const handleTerminalMouseDown = (e) => { - isResizingTerminal.current = true; - e.preventDefault(); - }; - - useEffect(() => { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, []); - - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e) => { - // Check if Ctrl (or Cmd on Mac) is pressed - if (e.ctrlKey || e.metaKey) { - switch (e.key.toLowerCase()) { - case 'n': - e.preventDefault(); - setShowNewFileDialog(true); - break; - case 's': - e.preventDefault(); - if (selectedFile && hasUnsavedChanges) { - handleMenuSave(); - } - break; - case 'd': - e.preventDefault(); - if (selectedFile) { - handleDownload(); - } - break; - case 'u': - e.preventDefault(); - fileInputRef.current?.click(); - break; - default: - break; - } - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - }; - }, [selectedFile, hasUnsavedChanges]); - - const handleDownload = () => { - if (!selectedFile) return; - const downloadUrl = `/api/files/download?path=${encodeURIComponent(selectedFile.path)}`; - const link = document.createElement('a'); - link.href = downloadUrl; - link.download = selectedFile.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - return ( -
- -
-
-
Explorer
- -
-
-
-
- -
-
-
- -
-
-
-
- ); -} - -export default App; diff --git a/third_party/agfs/agfs-shell/webapp/src/components/ContextMenu.jsx b/third_party/agfs/agfs-shell/webapp/src/components/ContextMenu.jsx deleted file mode 100644 index 0660b9e9e..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/components/ContextMenu.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -const ContextMenu = ({ x, y, onClose, items }) => { - const menuRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (e) => { - if (menuRef.current && !menuRef.current.contains(e.target)) { - onClose(); - } - }; - - const handleEscape = (e) => { - if (e.key === 'Escape') { - onClose(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); - }; - }, [onClose]); - - return ( -
- {items.map((item, index) => ( - item.separator ? ( -
- ) : ( -
{ - if (!item.disabled && item.onClick) { - item.onClick(); - onClose(); - } - }} - > - {item.icon} - {item.label} -
- ) - ))} -
- ); -}; - -export default ContextMenu; diff --git a/third_party/agfs/agfs-shell/webapp/src/components/Editor.jsx b/third_party/agfs/agfs-shell/webapp/src/components/Editor.jsx deleted file mode 100644 index 9bf45509c..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/components/Editor.jsx +++ /dev/null @@ -1,112 +0,0 @@ -import React, { useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; -import MonacoEditor from '@monaco-editor/react'; - -const Editor = forwardRef(({ file, content, onSave, onChange }, ref) => { - const editorRef = useRef(null); - - // Expose save method to parent via ref - useImperativeHandle(ref, () => ({ - save: () => { - if (editorRef.current) { - const value = editorRef.current.getValue(); - onSave(value); - } - } - })); - - const handleEditorDidMount = (editor, monaco) => { - editorRef.current = editor; - - // Add save shortcut (Ctrl+S / Cmd+S) - editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { - const value = editor.getValue(); - onSave(value); - }); - }; - - const handleEditorChange = (value) => { - // Notify parent of content change - if (onChange) { - onChange(value); - } - }; - - return ( - <> -
- {file ? ( -
- 📄 - {file.name} -
- ) : ( -
- Welcome -
- )} -
-
- {file ? ( - - ) : ( -
- Select a file to edit -
- )} -
- - ); -}); - -// Helper function to determine language from file extension -const getLanguageFromFilename = (filename) => { - const ext = filename.split('.').pop().toLowerCase(); - const languageMap = { - js: 'javascript', - jsx: 'javascript', - ts: 'typescript', - tsx: 'typescript', - py: 'python', - java: 'java', - c: 'c', - cpp: 'cpp', - cs: 'csharp', - php: 'php', - rb: 'ruby', - go: 'go', - rs: 'rust', - sql: 'sql', - sh: 'shell', - bash: 'shell', - json: 'json', - xml: 'xml', - html: 'html', - css: 'css', - scss: 'scss', - sass: 'sass', - md: 'markdown', - yaml: 'yaml', - yml: 'yaml', - toml: 'toml', - ini: 'ini', - txt: 'plaintext', - }; - return languageMap[ext] || 'plaintext'; -}; - -export default Editor; diff --git a/third_party/agfs/agfs-shell/webapp/src/components/FileTree.jsx b/third_party/agfs/agfs-shell/webapp/src/components/FileTree.jsx deleted file mode 100644 index f735476ad..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/components/FileTree.jsx +++ /dev/null @@ -1,321 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import ContextMenu from './ContextMenu'; - -const FileTreeItem = ({ item, depth, onSelect, selectedFile, onToggle, expanded, expandedDirs, onContextMenu }) => { - const isDirectory = item.type === 'directory'; - const isSelected = selectedFile && selectedFile.path === item.path; - - const handleClick = () => { - if (isDirectory) { - onToggle(item.path); - } - onSelect(item); - }; - - const handleContextMenu = (e) => { - e.preventDefault(); - onContextMenu(e, item); - }; - - return ( - <> -
- {isDirectory && ( - - ▶ - - )} - {!isDirectory && } - - {isDirectory ? '📁' : '📄'} - - {item.name} -
- {isDirectory && expanded && item.children && ( - item.children.map((child, index) => ( - - )) - )} - - ); -}; - -const FileTree = ({ currentPath, onFileSelect, selectedFile, wsRef, refreshTrigger }) => { - const [tree, setTree] = useState([]); - const [loading, setLoading] = useState(true); - const [expandedDirs, setExpandedDirs] = useState({ '/': true }); - const [pendingRequests, setPendingRequests] = useState(new Map()); - const [contextMenu, setContextMenu] = useState(null); - const [copiedItem, setCopiedItem] = useState(null); - - const loadDirectory = (path) => { - return new Promise((resolve, reject) => { - const ws = wsRef?.current; - if (!ws || ws.readyState !== WebSocket.OPEN) { - // Fallback to HTTP if WebSocket not available - fetch(`/api/files/list?path=${encodeURIComponent(path)}`) - .then(res => res.json()) - .then(data => resolve(data.files || [])) - .catch(reject); - return; - } - - // Use WebSocket - const requestId = `${path}-${Date.now()}`; - setPendingRequests(prev => new Map(prev).set(requestId, { resolve, reject, path })); - - ws.send(JSON.stringify({ - type: 'explorer', - path: path, - requestId: requestId - })); - - // Timeout after 5 seconds - setTimeout(() => { - setPendingRequests(prev => { - const newMap = new Map(prev); - if (newMap.has(requestId)) { - newMap.delete(requestId); - reject(new Error('Request timeout')); - } - return newMap; - }); - }, 5000); - }); - }; - - // Handle WebSocket messages for explorer - useEffect(() => { - const ws = wsRef?.current; - if (!ws) return; - - const handleMessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type === 'explorer') { - // Find matching pending request - setPendingRequests(prev => { - const newMap = new Map(prev); - for (const [requestId, request] of newMap) { - if (request.path === data.path) { - newMap.delete(requestId); - if (data.error) { - request.reject(new Error(data.error)); - } else { - request.resolve(data.files || []); - } - break; - } - } - return newMap; - }); - } - } catch (e) { - // Not a JSON message or not for us - } - }; - - ws.addEventListener('message', handleMessage); - return () => ws.removeEventListener('message', handleMessage); - }, [wsRef, pendingRequests]); - - const buildTree = async (path, depth = 0) => { - // Load directory contents - const items = await loadDirectory(path); - const result = []; - - for (const item of items) { - // WebSocket API already provides full path - const fullPath = item.path || (path === '/' ? `/${item.name}` : `${path}/${item.name}`); - const treeItem = { - name: item.name, - path: fullPath, - type: item.type, - size: item.size, - mtime: item.mtime, - }; - - // Recursively load children if directory is expanded - if (item.type === 'directory' && expandedDirs[fullPath]) { - treeItem.children = await buildTree(fullPath, depth + 1); - } - - result.push(treeItem); - } - - return result.sort((a, b) => { - if (a.type === b.type) return a.name.localeCompare(b.name); - return a.type === 'directory' ? -1 : 1; - }); - }; - - const handleToggle = async (path) => { - const newExpanded = { ...expandedDirs }; - newExpanded[path] = !newExpanded[path]; - setExpandedDirs(newExpanded); - }; - - const handleContextMenu = (e, item) => { - setContextMenu({ - x: e.clientX, - y: e.clientY, - item: item - }); - }; - - const handleCopy = () => { - setCopiedItem(contextMenu.item); - }; - - const handlePaste = async () => { - if (!copiedItem || !contextMenu.item) return; - - const targetDir = contextMenu.item.type === 'directory' - ? contextMenu.item.path - : contextMenu.item.path.substring(0, contextMenu.item.path.lastIndexOf('/')) || '/'; - - const fileName = copiedItem.path.split('/').pop(); - const targetPath = targetDir === '/' ? `/${fileName}` : `${targetDir}/${fileName}`; - - try { - const response = await fetch('/api/files/copy', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - sourcePath: copiedItem.path, - targetPath: targetPath - }) - }); - - if (response.ok) { - // Refresh tree by updating expandedDirs - setExpandedDirs(prev => ({ ...prev })); - } else { - const data = await response.json(); - alert(`Failed to copy: ${data.error}`); - } - } catch (error) { - alert(`Failed to copy: ${error.message}`); - } - }; - - const handleDownload = () => { - if (!contextMenu.item) return; - const downloadUrl = `/api/files/download?path=${encodeURIComponent(contextMenu.item.path)}`; - const link = document.createElement('a'); - link.href = downloadUrl; - link.download = contextMenu.item.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - const handleDelete = async () => { - if (!contextMenu.item) return; - - if (!confirm(`Are you sure you want to delete "${contextMenu.item.name}"?`)) { - return; - } - - try { - const response = await fetch('/api/files/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ path: contextMenu.item.path }) - }); - - if (response.ok) { - // Refresh tree by updating expandedDirs - setExpandedDirs(prev => ({ ...prev })); - } else { - const data = await response.json(); - alert(`Failed to delete: ${data.error}`); - } - } catch (error) { - alert(`Failed to delete: ${error.message}`); - } - }; - - useEffect(() => { - const loadTree = async () => { - setLoading(true); - const data = await buildTree(currentPath); - setTree(data); - setLoading(false); - }; - loadTree(); - }, [currentPath, expandedDirs, refreshTrigger]); - - if (loading) { - return
Loading...
; - } - - const menuItems = contextMenu ? [ - { - icon: '📋', - label: 'Copy', - onClick: handleCopy - }, - { - icon: '📄', - label: 'Paste', - onClick: handlePaste, - disabled: !copiedItem - }, - { separator: true }, - { - icon: '⬇️', - label: 'Download', - onClick: handleDownload, - disabled: contextMenu.item.type === 'directory' - }, - { - icon: '🗑️', - label: 'Delete', - onClick: handleDelete - } - ] : []; - - return ( -
- {tree.map((item, index) => ( - - ))} - {contextMenu && ( - setContextMenu(null)} - /> - )} -
- ); -}; - -export default FileTree; diff --git a/third_party/agfs/agfs-shell/webapp/src/components/MenuBar.jsx b/third_party/agfs/agfs-shell/webapp/src/components/MenuBar.jsx deleted file mode 100644 index 2f87345ab..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/components/MenuBar.jsx +++ /dev/null @@ -1,147 +0,0 @@ -import React, { useState, useEffect } from 'react'; - -const MenuBar = ({ - onNewFile, - onSave, - onUpload, - onDownload, - currentFile, - currentDirectory, - hasUnsavedChanges, - showNewFileDialog, - onShowNewFileDialog, - fileInputRef -}) => { - const [newFilePath, setNewFilePath] = useState(''); - - // Set default path when dialog opens - useEffect(() => { - if (showNewFileDialog) { - const defaultPath = currentDirectory === '/' ? '/' : `${currentDirectory}/`; - setNewFilePath(defaultPath); - } - }, [showNewFileDialog, currentDirectory]); - - const handleNewFile = () => { - onShowNewFileDialog(true); - }; - - const handleCreateFile = async () => { - if (newFilePath.trim()) { - await onNewFile(newFilePath.trim()); - onShowNewFileDialog(false); - setNewFilePath(''); - } - }; - - const handleCancel = () => { - onShowNewFileDialog(false); - setNewFilePath(''); - }; - - const handleKeyDown = (e) => { - if (e.key === 'Enter') { - handleCreateFile(); - } else if (e.key === 'Escape') { - handleCancel(); - } - }; - - const isSaveDisabled = !currentFile || !hasUnsavedChanges; - const saveLabel = hasUnsavedChanges ? 'Save' : 'Saved'; - - const handleUploadClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = (e) => { - const files = Array.from(e.target.files || []); - if (files.length > 0) { - onUpload(files); - } - // Reset input so same file can be uploaded again - e.target.value = ''; - }; - - return ( - <> -
-
-
- AGFS Logo -
-
-
- 📄 - New File - Ctrl+N -
-
- {hasUnsavedChanges ? '💾' : '✓'} - {saveLabel} - Ctrl+S -
-
- ⬇️ - Download - Ctrl+D -
-
- ⬆️ - Upload - Ctrl+U -
-
-
-
- 📁 {currentDirectory} - {currentFile && ( - 📝 {currentFile.name} - )} -
-
- - - {showNewFileDialog && ( -
-
e.stopPropagation()}> -
Create New File
-
- - setNewFilePath(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="/path/to/file.txt" - autoFocus - /> -
-
- - -
-
-
- )} - - ); -}; - -export default MenuBar; diff --git a/third_party/agfs/agfs-shell/webapp/src/components/Terminal.jsx b/third_party/agfs/agfs-shell/webapp/src/components/Terminal.jsx deleted file mode 100644 index 87d3452fc..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/components/Terminal.jsx +++ /dev/null @@ -1,368 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import { Terminal as XTerm } from '@xterm/xterm'; -import { FitAddon } from '@xterm/addon-fit'; -import '@xterm/xterm/css/xterm.css'; - -const Terminal = ({ wsRef }) => { - const terminalRef = useRef(null); - const xtermRef = useRef(null); - const fitAddonRef = useRef(null); - const currentLineRef = useRef(''); - const commandHistoryRef = useRef([]); - const historyIndexRef = useRef(-1); - const completionsRef = useRef([]); - const completionIndexRef = useRef(0); - const lastCompletionTextRef = useRef(''); - const pendingCompletionRef = useRef(false); - const completionLineRef = useRef(''); - - useEffect(() => { - if (!terminalRef.current) return; - - // Initialize xterm - const term = new XTerm({ - cursorBlink: true, - fontSize: 14, - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - theme: { - background: '#1e1e1e', - foreground: '#cccccc', - cursor: '#ffffff', - selection: '#264f78', - black: '#000000', - red: '#cd3131', - green: '#0dbc79', - yellow: '#e5e510', - blue: '#2472c8', - magenta: '#bc3fbc', - cyan: '#11a8cd', - white: '#e5e5e5', - brightBlack: '#666666', - brightRed: '#f14c4c', - brightGreen: '#23d18b', - brightYellow: '#f5f543', - brightBlue: '#3b8eea', - brightMagenta: '#d670d6', - brightCyan: '#29b8db', - brightWhite: '#ffffff', - }, - allowProposedApi: true, - }); - - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - term.open(terminalRef.current); - fitAddon.fit(); - - xtermRef.current = term; - fitAddonRef.current = fitAddon; - - // WebSocket connection for terminal - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/terminal`; - const ws = new WebSocket(wsUrl); - - // Store in provided ref so FileTree can use it too - if (wsRef) { - wsRef.current = ws; - } - - ws.onopen = () => { - console.log('WebSocket connected'); - }; - - ws.onmessage = (event) => { - // Try to parse as JSON first (for completion responses and other structured data) - try { - const data = JSON.parse(event.data); - - // Handle completions - if (data.type === 'completions') { - // Only process if still pending and line hasn't changed - if (!pendingCompletionRef.current || currentLineRef.current !== completionLineRef.current) { - // User has already typed more, ignore stale completions - pendingCompletionRef.current = false; - return; - } - - pendingCompletionRef.current = false; - - // Handle completion response - const completions = data.completions || []; - completionsRef.current = completions; - - if (completions.length === 0) { - // No completions, do nothing - } else if (completions.length === 1) { - // Single completion - auto complete - const completion = completions[0]; - const currentLine = currentLineRef.current; - - // Find the last space to replace from there - const lastSpaceIndex = currentLine.lastIndexOf(' '); - let newLine; - if (lastSpaceIndex >= 0) { - // Replace text after last space - newLine = currentLine.substring(0, lastSpaceIndex + 1) + completion; - } else { - // Replace entire line - newLine = completion; - } - - // Clear current line and write new one - term.write('\r\x1b[K$ ' + newLine); - currentLineRef.current = newLine; - } else { - // Multiple completions - show them - term.write('\r\n'); - const maxPerLine = 3; - for (let i = 0; i < completions.length; i += maxPerLine) { - const slice = completions.slice(i, i + maxPerLine); - term.write(slice.join(' ') + '\r\n'); - } - term.write('$ ' + currentLineRef.current); - completionIndexRef.current = 0; - } - return; - } - - // Ignore explorer messages (handled by FileTree component) - if (data.type === 'explorer') { - return; - } - - // Ignore other JSON messages that are not for terminal display - return; - } catch (e) { - // Not JSON, treat as regular output - } - - // Write server output directly to terminal - term.write(event.data); - }; - - ws.onerror = (error) => { - console.error('WebSocket error:', error); - term.write('\r\n\x1b[31mWebSocket connection error\x1b[0m\r\n'); - }; - - ws.onclose = () => { - console.log('WebSocket closed'); - term.write('\r\n\x1b[33mConnection closed. Please refresh the page.\x1b[0m\r\n'); - }; - - // Handle terminal input - // Note: currentLine is kept in currentLineRef, which is shared between onData and onmessage - term.onData((data) => { - const code = data.charCodeAt(0); - let currentLine = currentLineRef.current || ''; - - // Handle Enter key - if (code === 13) { - term.write('\r\n'); - - if (currentLine.trim()) { - // Add to history - commandHistoryRef.current.push(currentLine); - historyIndexRef.current = commandHistoryRef.current.length; - - // Send command to server via WebSocket - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'command', - data: currentLine - })); - } else { - term.write('\x1b[31mNot connected to server\x1b[0m\r\n$ '); - } - - currentLine = ''; - currentLineRef.current = ''; - } else { - // Empty line, send to server to get new prompt - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'command', - data: '' - })); - } - } - } - // Handle Backspace - else if (code === 127) { - if (currentLine.length > 0) { - currentLine = currentLine.slice(0, -1); - currentLineRef.current = currentLine; - term.write('\b \b'); - } - } - // Handle Ctrl+C - else if (code === 3) { - term.write('^C\r\n$ '); - currentLine = ''; - currentLineRef.current = ''; - } - // Handle Ctrl+L (clear screen) - else if (code === 12) { - term.clear(); - term.write('$ ' + currentLine); - } - // Handle Ctrl+U (clear line) - else if (code === 21) { - // Clear current line - const lineLength = currentLine.length; - term.write('\r$ '); - term.write(' '.repeat(lineLength)); - term.write('\r$ '); - currentLine = ''; - currentLineRef.current = ''; - } - // Handle arrow up (previous command in history) - else if (data === '\x1b[A') { - if (commandHistoryRef.current.length > 0 && historyIndexRef.current > 0) { - // Clear current line - term.write('\r\x1b[K$ '); - - // Go back in history - historyIndexRef.current--; - currentLine = commandHistoryRef.current[historyIndexRef.current]; - currentLineRef.current = currentLine; - - // Write the command - term.write(currentLine); - } - } - // Handle arrow down (next command in history) - else if (data === '\x1b[B') { - // Clear current line - term.write('\r\x1b[K$ '); - - if (historyIndexRef.current < commandHistoryRef.current.length - 1) { - // Go forward in history - historyIndexRef.current++; - currentLine = commandHistoryRef.current[historyIndexRef.current]; - } else { - // At the end of history, clear line - historyIndexRef.current = commandHistoryRef.current.length; - currentLine = ''; - } - - currentLineRef.current = currentLine; - term.write(currentLine); - } - // Handle Ctrl+A (go to beginning of line) - else if (code === 1) { - term.write('\r$ '); - } - // Handle Ctrl+E (go to end of line) - else if (code === 5) { - term.write('\r$ ' + currentLine); - } - // Handle Ctrl+W (delete word before cursor) - else if (code === 23) { - if (currentLine.length > 0) { - // Find the last word boundary (space) - let newLine = currentLine.trimEnd(); - const lastSpaceIndex = newLine.lastIndexOf(' '); - - if (lastSpaceIndex >= 0) { - // Delete from last space to end - newLine = newLine.substring(0, lastSpaceIndex + 1); - } else { - // No space found, delete entire line - newLine = ''; - } - - // Clear line and rewrite - term.write('\r\x1b[K$ ' + newLine); - currentLine = newLine; - currentLineRef.current = newLine; - } - } - // Handle Tab (autocomplete) - else if (code === 9) { - if (ws.readyState === WebSocket.OPEN) { - // Mark as pending completion and save current line - pendingCompletionRef.current = true; - completionLineRef.current = currentLine; - - // Extract the word being completed - // Find the last space or start of line - const beforeCursor = currentLine; - const lastSpaceIndex = beforeCursor.lastIndexOf(' '); - const text = lastSpaceIndex >= 0 ? beforeCursor.substring(lastSpaceIndex + 1) : beforeCursor; - - // Send completion request - ws.send(JSON.stringify({ - type: 'complete', - text: text, - line: currentLine, - cursor_pos: currentLine.length - })); - } - } - // Handle arrow left/right (for now, ignore) - else if (data === '\x1b[C' || data === '\x1b[D') { - // Ignore arrow left/right for simplicity - } - // Handle regular characters - else if (code >= 32 && code < 127) { - currentLine += data; - currentLineRef.current = currentLine; - term.write(data); - } - }); - - // Handle window resize - const handleResize = () => { - fitAddon.fit(); - - // Send resize event to server - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ - type: 'resize', - data: { - cols: term.cols, - rows: term.rows - } - })); - } - }; - - window.addEventListener('resize', handleResize); - - // Prevent Ctrl+W from closing the browser tab - // Use capture phase and window-level listener for reliability - const handleKeyDown = (e) => { - // Check for Ctrl+W (or Cmd+W on Mac) - if ((e.ctrlKey || e.metaKey) && e.key === 'w') { - e.preventDefault(); - e.stopPropagation(); - } - }; - - // Add keydown listener to window with capture phase - window.addEventListener('keydown', handleKeyDown, true); - - // Cleanup - return () => { - window.removeEventListener('resize', handleResize); - window.removeEventListener('keydown', handleKeyDown, true); - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - term.dispose(); - }; - }, []); - - return ( - <> -
- TERMINAL -
-
- - ); -}; - -export default Terminal; diff --git a/third_party/agfs/agfs-shell/webapp/src/main.jsx b/third_party/agfs/agfs-shell/webapp/src/main.jsx deleted file mode 100644 index 1943cc824..000000000 --- a/third_party/agfs/agfs-shell/webapp/src/main.jsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' - -ReactDOM.createRoot(document.getElementById('root')).render( - - - , -) diff --git a/third_party/agfs/agfs-shell/webapp/vite.config.js b/third_party/agfs/agfs-shell/webapp/vite.config.js deleted file mode 100644 index d267b1814..000000000 --- a/third_party/agfs/agfs-shell/webapp/vite.config.js +++ /dev/null @@ -1,21 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -export default defineConfig({ - plugins: [react()], - build: { - outDir: 'dist', - }, - server: { - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - }, - '/ws': { - target: 'ws://localhost:8080', - ws: true, - } - } - } -}) diff --git a/third_party/agfs/assets/logo-white.png b/third_party/agfs/assets/logo-white.png deleted file mode 100644 index 3329d1c87..000000000 Binary files a/third_party/agfs/assets/logo-white.png and /dev/null differ diff --git a/third_party/agfs/assets/logo.png b/third_party/agfs/assets/logo.png deleted file mode 100644 index 3810866da..000000000 Binary files a/third_party/agfs/assets/logo.png and /dev/null differ diff --git a/third_party/agfs/install.sh b/third_party/agfs/install.sh deleted file mode 100755 index a62cfedcb..000000000 --- a/third_party/agfs/install.sh +++ /dev/null @@ -1,331 +0,0 @@ -#!/bin/sh -set -e - -# AGFS Installation Script -# This script downloads and installs the latest daily build of agfs-server and agfs-shell - -REPO="c4pt0r/agfs" -INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" -AGFS_SHELL_DIR="${AGFS_SHELL_DIR:-$HOME/.local/agfs-shell}" -INSTALL_SERVER="${INSTALL_SERVER:-yes}" -INSTALL_CLIENT="${INSTALL_CLIENT:-yes}" - -# Detect OS and architecture -detect_platform() { - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m) - - case "$OS" in - linux) - OS="linux" - ;; - darwin) - OS="darwin" - ;; - mingw* | msys* | cygwin*) - OS="windows" - ;; - *) - echo "Error: Unsupported operating system: $OS" - exit 1 - ;; - esac - - case "$ARCH" in - x86_64 | amd64) - ARCH="amd64" - ;; - aarch64 | arm64) - ARCH="arm64" - ;; - *) - echo "Error: Unsupported architecture: $ARCH" - exit 1 - ;; - esac - - echo "Detected platform: $OS-$ARCH" -} - -# Get the nightly build tag -get_latest_tag() { - echo "Fetching nightly build..." - LATEST_TAG="nightly" - echo "Using nightly build" -} - -# Check Python version -check_python() { - if ! command -v python3 >/dev/null 2>&1; then - echo "Warning: python3 not found. agfs-shell requires Python 3.10+" - return 1 - fi - - PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') - PYTHON_MAJOR=$(echo "$PYTHON_VERSION" | cut -d. -f1) - PYTHON_MINOR=$(echo "$PYTHON_VERSION" | cut -d. -f2) - - if [ "$PYTHON_MAJOR" -lt 3 ] || { [ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 10 ]; }; then - echo "Warning: Python $PYTHON_VERSION found, but agfs-shell requires Python 3.10+" - return 1 - fi - - echo "Found Python $PYTHON_VERSION" - return 0 -} - -# Install agfs-server -install_server() { - echo "" - echo "Installing agfs-server..." - - # Get the date from the nightly release - DATE=$(curl -sL "https://api.github.com/repos/$REPO/releases/tags/$LATEST_TAG" | \ - grep '"name":' | \ - head -n 1 | \ - sed -E 's/.*\(([0-9]+)\).*/\1/') - - if [ -z "$DATE" ]; then - echo "Error: Could not determine build date from nightly release" - exit 1 - fi - - if [ "$OS" = "windows" ]; then - ARCHIVE="agfs-${OS}-${ARCH}-${DATE}.zip" - BINARY="agfs-server-${OS}-${ARCH}.exe" - else - ARCHIVE="agfs-${OS}-${ARCH}-${DATE}.tar.gz" - BINARY="agfs-server-${OS}-${ARCH}" - fi - - DOWNLOAD_URL="https://github.com/$REPO/releases/download/$LATEST_TAG/$ARCHIVE" - - echo "Downloading from: $DOWNLOAD_URL" - - TMP_DIR=$(mktemp -d) - cd "$TMP_DIR" - - if ! curl -fsSL -o "$ARCHIVE" "$DOWNLOAD_URL"; then - echo "Error: Failed to download $ARCHIVE" - rm -rf "$TMP_DIR" - exit 1 - fi - - echo "Extracting archive..." - if [ "$OS" = "windows" ]; then - unzip -q "$ARCHIVE" - else - tar -xzf "$ARCHIVE" - fi - - if [ ! -f "$BINARY" ]; then - echo "Error: Binary $BINARY not found in archive" - rm -rf "$TMP_DIR" - exit 1 - fi - - # Create install directory if it doesn't exist - mkdir -p "$INSTALL_DIR" - - # Install binary - mv "$BINARY" "$INSTALL_DIR/agfs-server" - chmod +x "$INSTALL_DIR/agfs-server" - - # Clean up - cd - > /dev/null - rm -rf "$TMP_DIR" - - echo "✓ agfs-server installed to $INSTALL_DIR/agfs-server" - - # Install systemd service on Linux systems - if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1; then - install_systemd_service - fi -} - -# Install systemd service -install_systemd_service() { - echo "" - echo "Installing systemd service..." - - # Download service file template (use master branch, not release tag) - SERVICE_URL="https://raw.githubusercontent.com/$REPO/master/agfs-server/agfs-server.service" - TMP_SERVICE=$(mktemp) - - if ! curl -fsSL -o "$TMP_SERVICE" "$SERVICE_URL" 2>/dev/null; then - echo "Warning: Could not download systemd service file, skipping service installation" - rm -f "$TMP_SERVICE" - return 1 - fi - - # Get current user and group - CURRENT_USER=$(whoami) - CURRENT_GROUP=$(id -gn) - - # Replace placeholders - sed -e "s|%USER%|$CURRENT_USER|g" \ - -e "s|%GROUP%|$CURRENT_GROUP|g" \ - -e "s|%INSTALL_DIR%|$INSTALL_DIR|g" \ - "$TMP_SERVICE" > "$TMP_SERVICE.processed" - - # Install systemd service (requires root/sudo) - if [ "$CURRENT_USER" = "root" ]; then - # Running as root - cp "$TMP_SERVICE.processed" /etc/systemd/system/agfs-server.service - systemctl daemon-reload - echo "✓ systemd service installed to /etc/systemd/system/agfs-server.service" - echo "" - echo "To enable and start the service:" - echo " systemctl enable agfs-server" - echo " systemctl start agfs-server" - else - # Require sudo with password prompt - echo "Installing systemd service requires root privileges." - if ! sudo cp "$TMP_SERVICE.processed" /etc/systemd/system/agfs-server.service; then - echo "Error: Failed to install systemd service (sudo required)" - rm -f "$TMP_SERVICE" "$TMP_SERVICE.processed" - return 1 - fi - sudo systemctl daemon-reload - echo "✓ systemd service installed to /etc/systemd/system/agfs-server.service" - echo "" - echo "To enable and start the service:" - echo " sudo systemctl enable agfs-server" - echo " sudo systemctl start agfs-server" - fi - - rm -f "$TMP_SERVICE" "$TMP_SERVICE.processed" -} - -# Install agfs-shell -install_client() { - echo "" - echo "Installing agfs-shell..." - - # Check Python - if ! check_python; then - echo "Skipping agfs-shell installation (Python requirement not met)" - return 1 - fi - - # Only build for supported platforms - if [ "$OS" = "windows" ]; then - if [ "$ARCH" != "amd64" ] && [ "$ARCH" != "arm64" ]; then - echo "Skipping agfs-shell: Not available for $OS-$ARCH" - return 1 - fi - SHELL_ARCHIVE="agfs-shell-${OS}-${ARCH}.zip" - else - if [ "$ARCH" != "amd64" ] && ! { [ "$OS" = "darwin" ] && [ "$ARCH" = "arm64" ]; } && ! { [ "$OS" = "linux" ] && [ "$ARCH" = "arm64" ]; }; then - echo "Skipping agfs-shell: Not available for $OS-$ARCH" - return 1 - fi - SHELL_ARCHIVE="agfs-shell-${OS}-${ARCH}.tar.gz" - fi - - SHELL_URL="https://github.com/$REPO/releases/download/$LATEST_TAG/$SHELL_ARCHIVE" - - echo "Downloading from: $SHELL_URL" - - TMP_DIR=$(mktemp -d) - cd "$TMP_DIR" - - if ! curl -fsSL -o "$SHELL_ARCHIVE" "$SHELL_URL"; then - echo "Warning: Failed to download agfs-shell, skipping client installation" - rm -rf "$TMP_DIR" - return 1 - fi - - echo "Extracting archive..." - if [ "$OS" = "windows" ]; then - unzip -q "$SHELL_ARCHIVE" - else - tar -xzf "$SHELL_ARCHIVE" - fi - - if [ ! -d "agfs-shell-portable" ]; then - echo "Error: agfs-shell-portable directory not found in archive" - rm -rf "$TMP_DIR" - return 1 - fi - - # Remove old installation - rm -rf "$AGFS_SHELL_DIR" - mkdir -p "$AGFS_SHELL_DIR" - - # Copy portable directory - cp -r agfs-shell-portable/* "$AGFS_SHELL_DIR/" - - # Create symlink (rename to 'agfs' for convenience) - mkdir -p "$INSTALL_DIR" - ln -sf "$AGFS_SHELL_DIR/agfs-shell" "$INSTALL_DIR/agfs" - - # Clean up - cd - > /dev/null - rm -rf "$TMP_DIR" - - echo "✓ agfs-shell installed to $AGFS_SHELL_DIR" - echo " Symlink created: $INSTALL_DIR/agfs" -} - -show_completion() { - echo "" - echo "----------------------------------" - echo " Installation completed!" - echo "----------------------------------" - echo "" - - if [ "$INSTALL_SERVER" = "yes" ]; then - echo "Server: agfs-server" - echo " Location: $INSTALL_DIR/agfs-server" - echo " Usage: agfs-server --help" - echo "" - fi - - if [ "$INSTALL_CLIENT" = "yes" ] && [ -f "$INSTALL_DIR/agfs" ]; then - echo "Client: agfs" - echo " Location: $INSTALL_DIR/agfs" - echo " Usage: agfs --help" - echo " Interactive: agfs" - echo "" - fi - - # Check if install dir is in PATH - case ":$PATH:" in - *":$INSTALL_DIR:"*) - ;; - *) - echo "Note: $INSTALL_DIR is not in your PATH." - echo "Add it to your PATH by adding this to ~/.bashrc or ~/.zshrc:" - echo " export PATH=\"\$PATH:$INSTALL_DIR\"" - echo "" - ;; - esac - - echo "Quick Start:" - echo " 1. Start server: agfs-server" - echo " 2. Use client: agfs" -} - -main() { - echo "" - echo "----------------------------------" - echo " AGFS Installer " - echo "----------------------------------" - echo "" - - detect_platform - get_latest_tag - - if [ "$INSTALL_SERVER" = "yes" ]; then - install_server - fi - - if [ "$INSTALL_CLIENT" = "yes" ]; then - install_client || true # Don't fail if client install fails - fi - - show_completion -} - -main