diff --git a/.github/workflows/code_interpreter_tests.yml b/.github/workflows/code_interpreter_tests.yml new file mode 100644 index 00000000..c84deb8d --- /dev/null +++ b/.github/workflows/code_interpreter_tests.yml @@ -0,0 +1,66 @@ +name: Test Code Interpreter + +on: + workflow_call: + inputs: + E2B_DOMAIN: + required: false + type: string + default: '' + E2B_TESTS_TEMPLATE: + required: true + type: string + run_recovery_tests: + required: false + type: boolean + default: true + secrets: + E2B_API_KEY: + required: true + +permissions: + contents: read + +jobs: + test: + defaults: + run: + working-directory: ./tests + shell: bash + name: Code Interpreter - HTTP tests + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install uv + uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + with: + version: '${{ env.TOOL_VERSION_UV }}' + python-version: '${{ env.TOOL_VERSION_PYTHON }}' + enable-cache: true + + - name: Install dependencies + run: uv sync --locked + + - name: Run tests + run: | + if [[ "${{ inputs.run_recovery_tests }}" == "true" ]]; then + uv run pytest --verbose --numprocesses=1 + else + uv run pytest --verbose --numprocesses=1 \ + --ignore=test_interrupt.py \ + --ignore=test_systemd.py \ + --deselect=test_async.py::test_async_interrupt + fi + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + E2B_TESTS_TEMPLATE: ${{ inputs.E2B_TESTS_TEMPLATE }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3343ed6d..42e84c5c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -48,9 +48,9 @@ jobs: enable-cache: true - name: Install Python dependencies - working-directory: chart_data_extractor run: | - uv sync --locked + (cd chart_data_extractor && uv sync --locked) + (cd tests && uv sync --locked) uv tool install ruff==0.11.12 - name: Run linting diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 592b81a4..c38ba959 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -20,9 +20,17 @@ jobs: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} with: E2B_DOMAIN: ${{ vars.E2B_DOMAIN }} + code-interpreter-tests: + uses: ./.github/workflows/code_interpreter_tests.yml + needs: [build-template] + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + with: + E2B_DOMAIN: ${{ vars.E2B_DOMAIN }} + E2B_TESTS_TEMPLATE: ${{ needs.build-template.outputs.template_id }} cleanup-build-template: uses: ./.github/workflows/cleanup_build_template.yml - needs: [build-template] + needs: [build-template, code-interpreter-tests] if: always() && !contains(needs.build-template.result, 'failure') && !contains(needs.build-template.result, 'cancelled') secrets: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} diff --git a/.github/workflows/server_tests.yml b/.github/workflows/server_tests.yml deleted file mode 100644 index 0ba67302..00000000 --- a/.github/workflows/server_tests.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Interpreter Server Tests - -on: - pull_request: - -permissions: - contents: read - -jobs: - test: - name: Interpreter Server Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - - name: Parse .tool-versions - uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1 - with: - filename: '.tool-versions' - uppercase: 'true' - prefix: 'tool_version_' - - - name: Install uv - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 - with: - version: '${{ env.TOOL_VERSION_UV }}' - python-version: '${{ env.TOOL_VERSION_PYTHON }}' - enable-cache: true - - - name: Test interpreter server - run: uv run --with-requirements template/requirements-test.txt pytest template/tests diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb5a1acc..a1e5e3a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,8 @@ importers: template: {} + tests: {} + packages: '@babel/runtime@7.29.7': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 08a7b833..37da767c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - chart_data_extractor - template + - tests # Only install package versions that have been on the registry for at least # 3 days, so malicious releases have time to be caught and unpublished. diff --git a/template/pytest.ini b/template/pytest.ini deleted file mode 100644 index e2378df8..00000000 --- a/template/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -pythonpath = server -asyncio_mode = auto -testpaths = tests diff --git a/template/requirements-test.txt b/template/requirements-test.txt deleted file mode 100644 index 134baa42..00000000 --- a/template/requirements-test.txt +++ /dev/null @@ -1,5 +0,0 @@ --r server/requirements.txt -pytest==9.0.3 -pytest-asyncio==1.3.0 -jupyter-server==2.20.0 -ipykernel==6.31.0 diff --git a/template/tests/test_jupyter_socket.py b/template/tests/test_jupyter_socket.py deleted file mode 100644 index 250aed45..00000000 --- a/template/tests/test_jupyter_socket.py +++ /dev/null @@ -1,96 +0,0 @@ -import asyncio -import os -import socket -import sys -from pathlib import Path - -import httpx -from anyio import fail_after - -import envs -import messaging -from api.models.output import OutputType -from messaging import ContextWebSocket - - -async def test_jupyter_http_and_execution_use_unix_socket_only(tmp_path, monkeypatch): - path = str(tmp_path / "jupyter.sock") - config = Path(__file__).resolve().parents[1] / "jupyter_server_config.py" - monkeypatch.setattr(messaging, "JUPYTER_SOCKET_PATH", path) - monkeypatch.setattr(envs, "LOCAL", True) - with (tmp_path / "jupyter.log").open("w+") as logs: - process = await asyncio.create_subprocess_exec( - sys.executable, - "-m", - "jupyter_server", - f"--config={config}", - f"--ServerApp.sock={path}", - f"--ServerApp.root_dir={tmp_path}", - "--IdentityProvider.token=", - stdout=logs, - stderr=logs, - env={ - **os.environ, - "IPYTHONDIR": str(tmp_path / "ipython"), - "JUPYTER_RUNTIME_DIR": str(tmp_path), - }, - ) - ws = None - try: - async with httpx.AsyncClient( - transport=httpx.AsyncHTTPTransport(uds=path), - base_url="http://localhost", - trust_env=False, - ) as client: - with fail_after(30): - while True: - if process.returncode is not None: - logs.seek(0) - raise AssertionError(logs.read()) - try: - response = await client.get("/api/status") - if response.status_code == 200: - break - except httpx.RequestError: - pass - await asyncio.sleep(0.1) - - assert Path(path).stat().st_mode & 0o777 == 0o600 - with socket.socket() as tcp: - assert tcp.connect_ex(("127.0.0.1", 8888)) != 0 - - response = await client.post("/api/kernels", json={"name": "python3"}) - response.raise_for_status() - kernel_id = response.json()["id"] - ws = ContextWebSocket(kernel_id, "session", "python", str(tmp_path)) - await ws.connect() - with fail_after(30): - outputs = [item async for item in ws.execute("6 * 7", {}, None)] - assert any( - item["type"] == OutputType.RESULT and item["text"] == "42" - for item in outputs - ), outputs - outputs = [] - with fail_after(10): - async for item in ws.execute( - 'print("started", flush=True)\nimport time\ntime.sleep(60)', - {}, - None, - ): - outputs.append(item) - if item["type"] == OutputType.STDOUT: - await ws.interrupt() - assert any( - item["type"] == OutputType.ERROR - and item["name"] == "KeyboardInterrupt" - for item in outputs - ), outputs - assert ( - await client.delete(f"/api/kernels/{kernel_id}") - ).status_code == 204 - finally: - if ws is not None: - await ws.close() - if process.returncode is None: - process.terminate() - await process.wait() diff --git a/tests/.env.example b/tests/.env.example new file mode 100644 index 00000000..c8d7360f --- /dev/null +++ b/tests/.env.example @@ -0,0 +1,7 @@ +E2B_API_KEY= +# Optional, defaults to e2b.app +E2B_DOMAIN= +# Template alias/ID to create sandboxes from, defaults to code-interpreter-v1 +E2B_TESTS_TEMPLATE= +# Set to true to run against a local server on http://localhost:49999 (`make start-template-server`) +E2B_DEBUG= diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..b07ebae9 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,20 @@ +# Code Interpreter HTTP tests + +End-to-end tests for the Code Interpreter template. Sandboxes are created with +the `e2b` SDK from `E2B_TESTS_TEMPLATE` (defaults to `code-interpreter-v1`); +everything else talks to the server's HTTP API on port `49999` directly with +`httpx`, so the server protocol is what's under test, not the SDK. See +`.env.example` for configuration. + +```bash +uv sync --locked +E2B_API_KEY=... uv run pytest +``` + +Set `E2B_DEBUG=true` to run against a local server started with +`make start-template-server`; tests marked `skip_debug` (sandbox provisioning, +optional kernels, recovery) are skipped in that mode. + +A few checks need a shell inside the sandbox (systemd restarts, the private +Jupyter Unix socket in `test_jupyter_socket.py`); those use the SDK's +`sandbox.commands.run` and are otherwise asserted the same way. diff --git a/tests/charts/__init__.py b/tests/charts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/charts/conftest.py b/tests/charts/conftest.py new file mode 100644 index 00000000..0ed45166 --- /dev/null +++ b/tests/charts/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from harness import CodeInterpreter + + +@pytest.fixture() +def run_chart(client: CodeInterpreter): + def run(code: str) -> dict: + execution = client.run_code(code) + assert execution.error is None, execution.error + chart = execution.results[0].chart + assert chart + return chart + + return run diff --git a/tests/charts/test_bar.py b/tests/charts/test_bar.py new file mode 100644 index 00000000..5f126a79 --- /dev/null +++ b/tests/charts/test_bar.py @@ -0,0 +1,43 @@ +code = """ +import matplotlib.pyplot as plt + +# Prepare data +authors = ['Author A', 'Author B', 'Author C', 'Author D'] +sales = [100, 200, 300, 400] + +# Create and customize the bar chart +plt.figure(figsize=(10, 6)) +plt.bar(authors, sales, label='Books Sold', color='blue') +plt.xlabel('Authors') +plt.ylabel('Number of Books Sold') +plt.title('Book Sales by Authors') + +# Display the chart +plt.tight_layout() +plt.show() +""" + + +def test_chart_bar(run_chart): + chart = run_chart(code) + + assert chart["type"] == "bar" + assert chart["title"] == "Book Sales by Authors" + + assert chart["x_label"] == "Authors" + assert chart["y_label"] == "Number of Books Sold" + + assert chart["x_unit"] is None + assert chart["y_unit"] is None + + bars = chart["elements"] + assert len(bars) == 4 + + assert [bar["value"] for bar in bars] == [100, 200, 300, 400] + assert [bar["label"] for bar in bars] == [ + "Author A", + "Author B", + "Author C", + "Author D", + ] + assert [bar["group"] for bar in bars] == ["Books Sold"] * 4 diff --git a/tests/charts/test_box_and_whiskers.py b/tests/charts/test_box_and_whiskers.py new file mode 100644 index 00000000..fd978afe --- /dev/null +++ b/tests/charts/test_box_and_whiskers.py @@ -0,0 +1,53 @@ +code = """ +import matplotlib.pyplot as plt +import numpy as np + +# Sample data +data = { + 'Class A': [85, 90, 78, 92, 88], + 'Class B': [95, 89, 76, 91, 84, 87], + 'Class C': [75, 82, 88, 79, 86] +} + +# Create figure and axis +fig, ax = plt.subplots(figsize=(10, 6)) + +# Customize plot +ax.set_title('Exam Scores Distribution') +ax.set_xlabel('Class') +ax.set_ylabel('Score') + +# Set custom colors +ax.boxplot(data.values(), labels=data.keys(), patch_artist=True) + +# Add legend +ax.legend() + +# Adjust layout and show plot +plt.tight_layout() +plt.show() +""" + + +def test_box_and_whiskers(run_chart): + chart = run_chart(code) + + assert chart["type"] == "box_and_whisker" + assert chart["title"] == "Exam Scores Distribution" + + assert chart["x_label"] == "Class" + assert chart["y_label"] == "Score" + + assert chart["x_unit"] is None + assert chart["y_unit"] is None + + bars = chart["elements"] + assert len(bars) == 3 + + assert [bar["outliers"] for bar in bars] == [[], [76], []] + assert [bar["min"] for bar in bars] == [78, 84, 75] + assert [bar["first_quartile"] for bar in bars] == [85, 84.75, 79] + assert [bar["median"] for bar in bars] == [88, 88, 82] + assert [bar["third_quartile"] for bar in bars] == [90, 90.5, 86] + assert [bar["max"] for bar in bars] == [92, 95, 88] + assert [bar["label"] for bar in bars] == ["Class A", "Class B", "Class C"] diff --git a/tests/charts/test_line.py b/tests/charts/test_line.py new file mode 100644 index 00000000..146f81fa --- /dev/null +++ b/tests/charts/test_line.py @@ -0,0 +1,70 @@ +import datetime + +code = """ +import numpy as np +import matplotlib.pyplot as plt +import datetime + +# Generate x values +dates = [datetime.date(2023, 9, 1) + datetime.timedelta(seconds=i) for i in range(100)] + +x = np.linspace(0, 2*np.pi, 100) +# Calculate y values +y_sin = np.sin(x) +y_cos = np.cos(x) + +# Create the plot +plt.figure(figsize=(10, 6)) +plt.plot(dates, y_sin, label='sin(x)') +plt.plot(dates, y_cos, label='cos(x)') + +# Add labels and title +plt.xlabel("Time (s)") +plt.ylabel("Amplitude (Hz)") +plt.title('Plot of sin(x) and cos(x)') + +# Display the plot +plt.show() +""" + + +def test_line_chart(run_chart): + chart = run_chart(code) + + assert chart["type"] == "line" + assert chart["title"] == "Plot of sin(x) and cos(x)" + + assert chart["x_label"] == "Time (s)" + assert chart["y_label"] == "Amplitude (Hz)" + + assert chart["x_unit"] == "s" + assert chart["y_unit"] == "Hz" + + assert chart["x_scale"] == "datetime" + assert chart["y_scale"] == "linear" + + assert all(isinstance(x, str) for x in chart["x_ticks"]) + assert isinstance( + datetime.datetime.fromisoformat(chart["x_ticks"][0]), datetime.datetime + ) + assert all(isinstance(y, float) for y in chart["y_ticks"]) + + assert all(isinstance(x, str) for x in chart["x_tick_labels"]) + assert all(isinstance(y, str) for y in chart["y_tick_labels"]) + + lines = chart["elements"] + assert len(lines) == 2 + + first_line = lines[0] + assert first_line["label"] == "sin(x)" + assert len(first_line["points"]) == 100 + assert all( + isinstance(x, str) and isinstance(y, float) for x, y in first_line["points"] + ) + assert isinstance( + datetime.datetime.fromisoformat(first_line["points"][0][0]), datetime.datetime + ) + + second_line = lines[1] + assert second_line["label"] == "cos(x)" + assert len(second_line["points"]) == 100 diff --git a/tests/charts/test_log_chart.py b/tests/charts/test_log_chart.py new file mode 100644 index 00000000..3c84a95a --- /dev/null +++ b/tests/charts/test_log_chart.py @@ -0,0 +1,55 @@ +code = """ +import numpy as np +import matplotlib.pyplot as plt + +# Generate x values +x = np.linspace(0, 100, 100) +# Calculate y values +y = np.exp(x) + +# Create the plot +plt.figure(figsize=(10, 6)) +plt.plot(x, y, label='y = e^x') + +# Set log scale for the y-axis +plt.yscale('log') + +# Add labels and title +plt.xlabel('X-axis') +plt.ylabel('Y-axis (log scale)') +plt.title('Chart with Log Scale on Y-axis') + +plt.legend() +plt.grid(True) +plt.show() +""" + + +def test_log_chart(run_chart): + chart = run_chart(code) + + assert chart["type"] == "line" + assert chart["title"] == "Chart with Log Scale on Y-axis" + + assert chart["x_label"] == "X-axis" + assert chart["y_label"] == "Y-axis (log scale)" + + assert chart["x_unit"] is None + assert chart["y_unit"] == "log scale" + + assert chart["x_scale"] == "linear" + assert chart["y_scale"] == "log" + + assert all(isinstance(x, float) for x in chart["x_ticks"]) + assert all(isinstance(y, float) for y in chart["y_ticks"]) + + assert all(isinstance(x, str) for x in chart["x_tick_labels"]) + assert all(isinstance(y, str) for y in chart["y_tick_labels"]) + + lines = chart["elements"] + assert len(lines) == 1 + + line = lines[0] + assert line["label"] == "y = e^x" + assert len(line["points"]) == 100 + assert all(isinstance(x, float) and isinstance(y, float) for x, y in line["points"]) diff --git a/tests/charts/test_pie.py b/tests/charts/test_pie.py new file mode 100644 index 00000000..045ee6f9 --- /dev/null +++ b/tests/charts/test_pie.py @@ -0,0 +1,43 @@ +code = """ +import matplotlib.pyplot as plt +import numpy as np + +# Step 1: Define the data for the pie chart +categories = ["No", "No, in blue"] +sizes = [90, 10] + +# Step 2: Create the figure and axis objects +fig, ax = plt.subplots(figsize=(8, 8)) + +plt.xlabel("x") +plt.ylabel("y") + +# Step 3: Create the pie chart +ax.pie(sizes, labels=categories, autopct='%1.1f%%', startangle=90, colors=plt.cm.Pastel1.colors[:len(categories)]) + +# Step 4: Add title and legend +ax.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle +plt.title('Will I wake up early tomorrow?') + +# Step 5: Show the plot +plt.show() +""" + + +def test_pie_chart(run_chart): + chart = run_chart(code) + + assert chart["type"] == "pie" + assert chart["title"] == "Will I wake up early tomorrow?" + + assert len(chart["elements"]) == 2 + + first_data = chart["elements"][0] + assert first_data["label"] == "No" + assert first_data["angle"] == 324 + assert first_data["radius"] == 1 + + second_data = chart["elements"][1] + assert second_data["label"] == "No, in blue" + assert second_data["angle"] == 36 + assert second_data["radius"] == 1 diff --git a/tests/charts/test_scale.py b/tests/charts/test_scale.py new file mode 100644 index 00000000..00da0eda --- /dev/null +++ b/tests/charts/test_scale.py @@ -0,0 +1,42 @@ +def test_datetime_scale(run_chart): + chart = run_chart( + """ + import numpy as np + import matplotlib.pyplot as plt + import datetime + + # Generate x values + dates = [datetime.date(2023, 9, 1) + datetime.timedelta(seconds=i) for i in range(100)] + y_sin = np.sin(np.linspace(0, 2*np.pi, 100)) + + # Create the plot + plt.figure(figsize=(10, 6)) + plt.plot(dates, y_sin, label='sin(x)') + plt.show() + """ + ) + + assert chart["type"] == "line" + assert chart["x_scale"] == "datetime" + assert chart["y_scale"] == "linear" + + +def test_categorical_scale(run_chart): + chart = run_chart( + """ + import numpy as np + import matplotlib.pyplot as plt + + x = [1, 2, 3, 4, 5] + y = ['A', 'B', 'C', 'D', 'E'] + + # Create the plot + plt.figure(figsize=(10, 6)) + plt.plot(x, y) + plt.show() + """ + ) + + assert chart["type"] == "line" + assert chart["x_scale"] == "linear" + assert chart["y_scale"] == "categorical" diff --git a/tests/charts/test_scatter.py b/tests/charts/test_scatter.py new file mode 100644 index 00000000..02c8672e --- /dev/null +++ b/tests/charts/test_scatter.py @@ -0,0 +1,50 @@ +code = """ +import matplotlib.pyplot as plt +import numpy as np + +# Create data +N = 5 +x1 = np.random.rand(N) +y1 = np.random.rand(N) +x2 = np.random.rand(2*N) +y2 = np.random.rand(2*N) + +plt.xlabel("A") +plt.ylabel("B") + +plt.scatter(x1, y1, c='blue', label='Dataset 1') +plt.scatter(x2, y2, c='red', label='Dataset 2') + +plt.show() +""" + + +def test_scatter_chart(run_chart): + chart = run_chart(code) + + assert chart["type"] == "scatter" + + assert chart["title"] is None + assert chart["x_label"] == "A" + assert chart["y_label"] == "B" + + assert chart["x_scale"] == "linear" + assert chart["y_scale"] == "linear" + + assert all(isinstance(x, float) for x in chart["x_ticks"]) + assert all(isinstance(y, float) for y in chart["y_ticks"]) + + assert all(isinstance(x, str) for x in chart["x_tick_labels"]) + assert all(isinstance(y, str) for y in chart["y_tick_labels"]) + + assert len(chart["elements"]) == 2 + + first_data = chart["elements"][0] + assert first_data["label"] == "Dataset 1" + assert len(first_data["points"]) == 5 + assert all(len(point) == 2 for point in first_data["points"]) + + second_data = chart["elements"][1] + assert second_data["label"] == "Dataset 2" + assert len(second_data["points"]) == 10 + assert all(len(point) == 2 for point in second_data["points"]) diff --git a/tests/charts/test_superchart.py b/tests/charts/test_superchart.py new file mode 100644 index 00000000..37a64b9a --- /dev/null +++ b/tests/charts/test_superchart.py @@ -0,0 +1,55 @@ +code = """ +import matplotlib.pyplot as plt +import numpy as np + +# Data for plotting +x1 = np.linspace(0, 10, 100) +y1 = np.sin(x1) + +# Create a figure with multiple subplots +fig, axs = plt.subplots(1, 2, figsize=(10, 8)) +fig.suptitle('Multiple Charts Example', fontsize=16) + +# Plotting on the different axes +axs[0].plot(x1, y1, 'r') +axs[0].set_title('Sine Wave') +axs[0].grid(True) + +N = 5 +x2 = np.random.rand(N) +y2 = np.random.rand(N) + +axs[1].scatter(x2, y2, c='blue', label='Dataset 1') +axs[1].set_xlabel('X') +axs[1].set_ylabel('Y') +axs[1].set_title('Scatter Plot') +axs[1].grid(True) + +plt.show() +""" + + +def test_super_chart(run_chart): + chart = run_chart(code) + + assert chart["type"] == "superchart" + assert chart["title"] == "Multiple Charts Example" + + charts = chart["elements"] + assert len(charts) == 2 + + first_chart = charts[0] + assert first_chart["type"] == "line" + assert first_chart["title"] == "Sine Wave" + assert first_chart["x_label"] is None + assert first_chart["y_label"] is None + assert len(first_chart["elements"]) == 1 + assert len(first_chart["elements"][0]["points"]) == 100 + + second_chart = charts[1] + assert second_chart["type"] == "scatter" + assert second_chart["title"] == "Scatter Plot" + assert second_chart["x_label"] == "X" + assert second_chart["y_label"] == "Y" + assert len(second_chart["elements"]) == 1 + assert len(second_chart["elements"][0]["points"]) == 5 diff --git a/tests/charts/test_unknown.py b/tests/charts/test_unknown.py new file mode 100644 index 00000000..fba4273c --- /dev/null +++ b/tests/charts/test_unknown.py @@ -0,0 +1,33 @@ +code = """ +import matplotlib.pyplot as plt +import numpy as np + +# Create a figure and an axis +fig, ax = plt.subplots() + +# Create data for two concentric circles +circle1 = plt.Circle((0, 0), 1, color='blue', fill=False, linewidth=2) +circle2 = plt.Circle((0, 0), 2, color='red', fill=False, linewidth=2) + +# Add the circles to the axes +ax.add_artist(circle1) +ax.add_artist(circle2) + +# Set grid +ax.grid(True) + +# Set title +plt.title('Two Concentric Circles') + +# Show the plot +plt.show() +""" + + +def test_unknown_charts(run_chart): + chart = run_chart(code) + + assert chart["type"] == "unknown" + assert chart["title"] == "Two Concentric Circles" + + assert chart["elements"] == [] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..fc06d9d3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,117 @@ +import os +import uuid +from typing import Callable, Optional + +import pytest +from dotenv import load_dotenv +from e2b import Sandbox + +from harness import AsyncCodeInterpreter, CodeInterpreter + +load_dotenv() + +DEFAULT_TEST_SANDBOX_TIMEOUT = 120 +CODE_INTERPRETER_PORT = 49999 +LOCAL_SERVER_URL = f"http://localhost:{CODE_INTERPRETER_PORT}" + + +def is_debug() -> bool: + return os.getenv("E2B_DEBUG", "false").lower() == "true" + + +@pytest.fixture(scope="session") +def sandbox_test_id() -> str: + return f"test_{uuid.uuid4()}" + + +@pytest.fixture(scope="session") +def template() -> str: + return os.getenv("E2B_TESTS_TEMPLATE") or "code-interpreter-v1" + + +@pytest.fixture() +def sandbox_factory( + request: pytest.FixtureRequest, template: str, sandbox_test_id: str +) -> Callable[..., Sandbox]: + # Skip lazily, on use: the debug branches of `client`/`async_client` + # depend on this fixture without ever calling it. + def factory( + *, + timeout: int = DEFAULT_TEST_SANDBOX_TIMEOUT, + envs: Optional[dict[str, str]] = None, + allow_public_traffic: bool = True, + ) -> Sandbox: + if is_debug(): + pytest.skip("Sandbox provisioning is not available in debug mode") + sandbox = Sandbox.create( + template, + timeout=timeout, + metadata={"sandbox_test_id": sandbox_test_id}, + envs=envs, + network={"allow_public_traffic": allow_public_traffic}, + ) + request.addfinalizer(sandbox.kill) + return sandbox + + return factory + + +@pytest.fixture() +def sandbox(sandbox_factory) -> Sandbox: + return sandbox_factory() + + +def code_interpreter_url(sandbox: Sandbox) -> str: + scheme = "http" if sandbox.connection_config.debug else "https" + return f"{scheme}://{sandbox.get_host(CODE_INTERPRETER_PORT)}" + + +def make_client(sandbox: Sandbox) -> CodeInterpreter: + return CodeInterpreter( + code_interpreter_url(sandbox), + envd_access_token=sandbox._envd_access_token, + traffic_access_token=sandbox.traffic_access_token, + ) + + +def make_async_client(sandbox: Sandbox) -> AsyncCodeInterpreter: + return AsyncCodeInterpreter( + code_interpreter_url(sandbox), + envd_access_token=sandbox._envd_access_token, + traffic_access_token=sandbox.traffic_access_token, + ) + + +@pytest.fixture() +def client_factory(request: pytest.FixtureRequest, sandbox_factory): + def factory(**sandbox_kwargs) -> CodeInterpreter: + client = make_client(sandbox_factory(**sandbox_kwargs)) + request.addfinalizer(client.close) + return client + + return factory + + +@pytest.fixture() +def client(request: pytest.FixtureRequest, client_factory) -> CodeInterpreter: + if is_debug(): + client = CodeInterpreter(LOCAL_SERVER_URL) + request.addfinalizer(client.close) + return client + return client_factory() + + +@pytest.fixture() +async def async_client(request: pytest.FixtureRequest, sandbox_factory): + if is_debug(): + client = AsyncCodeInterpreter(LOCAL_SERVER_URL) + else: + client = make_async_client(sandbox_factory()) + yield client + await client.aclose() + + +@pytest.fixture(autouse=True) +def skip_debug(request: pytest.FixtureRequest): + if request.node.get_closest_marker("skip_debug") and is_debug(): + pytest.skip("Skipped in debug mode") diff --git a/tests/env_vars/__init__.py b/tests/env_vars/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/env_vars/test_bash.py b/tests/env_vars/test_bash.py new file mode 100644 index 00000000..3d3b639e --- /dev/null +++ b/tests/env_vars/test_bash.py @@ -0,0 +1,30 @@ +import pytest + +from harness import CodeInterpreter + + +@pytest.mark.skip_debug +def test_env_vars_on_sandbox(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + execution = client.run_code("echo $TEST_ENV_VAR", language="bash") + assert execution.stdout[0] == "supertest\n" + + +def test_env_vars_per_execution(client: CodeInterpreter): + execution = client.run_code("echo $FOO", envs={"FOO": "bar"}, language="bash") + execution_empty = client.run_code("echo ${FOO:-default}", language="bash") + + assert execution.stdout[0] == "bar\n" + assert execution_empty.stdout[0] == "default\n" + + +@pytest.mark.skip_debug +def test_env_vars_overwrite(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + execution = client.run_code( + "echo $TEST_ENV_VAR", language="bash", envs={"TEST_ENV_VAR": "overwrite"} + ) + execution_global_default = client.run_code("echo $TEST_ENV_VAR", language="bash") + + assert execution.stdout[0] == "overwrite\n" + assert execution_global_default.stdout[0] == "supertest\n" diff --git a/tests/env_vars/test_java.py b/tests/env_vars/test_java.py new file mode 100644 index 00000000..e719f298 --- /dev/null +++ b/tests/env_vars/test_java.py @@ -0,0 +1,46 @@ +import pytest + +from harness import CodeInterpreter + +pytestmark = pytest.mark.skip_debug + + +def test_env_vars_on_sandbox(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + client.wait_for_kernel("java") + execution = client.run_code('System.getProperty("TEST_ENV_VAR")', language="java") + assert execution.text is not None + assert execution.text.strip() == "supertest" + + +def test_env_vars_per_execution(client: CodeInterpreter): + client.wait_for_kernel("java") + execution = client.run_code( + 'System.getProperty("FOO")', envs={"FOO": "bar"}, language="java" + ) + execution_empty = client.run_code( + 'System.getProperty("FOO", "default")', language="java" + ) + + assert execution.text is not None + assert execution.text.strip() == "bar" + assert execution_empty.text is not None + assert execution_empty.text.strip() == "default" + + +def test_env_vars_overwrite(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + client.wait_for_kernel("java") + execution = client.run_code( + 'System.getProperty("TEST_ENV_VAR")', + language="java", + envs={"TEST_ENV_VAR": "overwrite"}, + ) + execution_global_default = client.run_code( + 'System.getProperty("TEST_ENV_VAR")', language="java" + ) + + assert execution.text is not None + assert execution.text.strip() == "overwrite" + assert execution_global_default.text is not None + assert execution_global_default.text.strip() == "supertest" diff --git a/tests/env_vars/test_js.py b/tests/env_vars/test_js.py new file mode 100644 index 00000000..93646655 --- /dev/null +++ b/tests/env_vars/test_js.py @@ -0,0 +1,43 @@ +import pytest + +from harness import CodeInterpreter + + +@pytest.mark.skip_debug +def test_env_vars_on_sandbox(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + execution = client.run_code("process.env.TEST_ENV_VAR", language="javascript") + assert execution.text is not None + assert execution.text.strip() == "supertest" + + +def test_env_vars_per_execution(client: CodeInterpreter): + execution = client.run_code( + "process.env.FOO", envs={"FOO": "bar"}, language="javascript" + ) + execution_empty = client.run_code( + "process.env.FOO || 'default'", language="javascript" + ) + + assert execution.text is not None + assert execution.text.strip() == "bar" + assert execution_empty.text is not None + assert execution_empty.text.strip() == "default" + + +@pytest.mark.skip_debug +def test_env_vars_overwrite(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + execution = client.run_code( + "process.env.TEST_ENV_VAR", + language="javascript", + envs={"TEST_ENV_VAR": "overwrite"}, + ) + execution_global_default = client.run_code( + "process.env.TEST_ENV_VAR", language="javascript" + ) + + assert execution.text is not None + assert execution.text.strip() == "overwrite" + assert execution_global_default.text is not None + assert execution_global_default.text.strip() == "supertest" diff --git a/tests/env_vars/test_python.py b/tests/env_vars/test_python.py new file mode 100644 index 00000000..729528d5 --- /dev/null +++ b/tests/env_vars/test_python.py @@ -0,0 +1,40 @@ +import pytest + +from harness import CodeInterpreter + + +@pytest.mark.skip_debug +def test_env_vars_on_sandbox(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + execution = client.run_code( + "import os; os.getenv('TEST_ENV_VAR')", language="python" + ) + assert execution.text == "supertest" + + +def test_env_vars_per_execution(client: CodeInterpreter): + execution = client.run_code( + "import os; os.getenv('FOO')", envs={"FOO": "bar"}, language="python" + ) + execution_empty = client.run_code( + "import os; os.getenv('FOO', 'default')", language="python" + ) + + assert execution.text == "bar" + assert execution_empty.text == "default" + + +@pytest.mark.skip_debug +def test_env_vars_overwrite(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + execution = client.run_code( + "import os; os.getenv('TEST_ENV_VAR')", + language="python", + envs={"TEST_ENV_VAR": "overwrite"}, + ) + execution_global_default = client.run_code( + "import os; os.getenv('TEST_ENV_VAR')", language="python" + ) + + assert execution.text == "overwrite" + assert execution_global_default.text == "supertest" diff --git a/tests/env_vars/test_r.py b/tests/env_vars/test_r.py new file mode 100644 index 00000000..c16568dc --- /dev/null +++ b/tests/env_vars/test_r.py @@ -0,0 +1,38 @@ +import pytest + +from harness import CodeInterpreter + + +@pytest.mark.skip_debug +def test_env_vars_on_sandbox(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + client.wait_for_kernel("r") + execution = client.run_code("Sys.getenv('TEST_ENV_VAR')", language="r") + assert execution.results[0].text.strip() == '[1] "supertest"' + + +@pytest.mark.skip_debug +def test_env_vars_per_execution(client: CodeInterpreter): + client.wait_for_kernel("r") + execution = client.run_code("Sys.getenv('FOO')", envs={"FOO": "bar"}, language="r") + execution_empty = client.run_code( + "Sys.getenv('FOO', unset = 'default')", language="r" + ) + + assert execution.results[0].text.strip() == '[1] "bar"' + assert execution_empty.results[0].text.strip() == '[1] "default"' + + +@pytest.mark.skip_debug +def test_env_vars_overwrite(client_factory): + client = client_factory(envs={"TEST_ENV_VAR": "supertest"}) + client.wait_for_kernel("r") + execution = client.run_code( + "Sys.getenv('TEST_ENV_VAR')", language="r", envs={"TEST_ENV_VAR": "overwrite"} + ) + execution_global_default = client.run_code( + "Sys.getenv('TEST_ENV_VAR')", language="r" + ) + + assert execution.results[0].text.strip() == '[1] "overwrite"' + assert execution_global_default.results[0].text.strip() == '[1] "supertest"' diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py new file mode 100644 index 00000000..b1e89772 --- /dev/null +++ b/tests/harness/__init__.py @@ -0,0 +1,17 @@ +from .code_interpreter import ( + AsyncCodeInterpreter, + CodeInterpreter, + CodeInterpreterError, + Execution, + ExecutionError, + Result, +) + +__all__ = [ + "AsyncCodeInterpreter", + "CodeInterpreter", + "CodeInterpreterError", + "Execution", + "ExecutionError", + "Result", +] diff --git a/tests/harness/code_interpreter.py b/tests/harness/code_interpreter.py new file mode 100644 index 00000000..88a2912f --- /dev/null +++ b/tests/harness/code_interpreter.py @@ -0,0 +1,303 @@ +"""httpx client for the Code Interpreter HTTP API (port 49999). + +Mirrors the wire protocol only: JSON requests and a newline-delimited JSON +stream of output events terminated by ``{"type": "end_of_execution"}``. +""" + +import json +import textwrap +from dataclasses import dataclass, field +from typing import AsyncIterator, Iterator, Optional + +import httpx + +KERNEL_READINESS_ATTEMPTS = 4 +DEFAULT_EXECUTION_TIMEOUT = 60 +DEFAULT_REQUEST_TIMEOUT = 30 + +DATA_FORMATS = ( + "text", + "html", + "markdown", + "svg", + "png", + "jpeg", + "pdf", + "latex", + "json", + "javascript", + "data", + "chart", +) + + +class CodeInterpreterError(Exception): + def __init__(self, response: httpx.Response): + self.status_code = response.status_code + self.body = response.text + super().__init__( + f"{response.status_code} {response.reason_phrase}: {self.body}" + ) + + +@dataclass +class Result: + raw: dict + + def __getattr__(self, name: str): + if name in DATA_FORMATS or name in ("extra", "is_main_result"): + return self.raw.get(name) + raise AttributeError(name) + + def formats(self) -> list[str]: + return [fmt for fmt in DATA_FORMATS if self.raw.get(fmt) is not None] + + +@dataclass +class ExecutionError: + name: str + value: str + traceback: str + + +@dataclass +class Execution: + events: list[dict] = field(default_factory=list) + results: list[Result] = field(default_factory=list) + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + error: Optional[ExecutionError] = None + execution_count: Optional[int] = None + completed: bool = False + + @property + def text(self) -> Optional[str]: + for result in self.results: + if result.is_main_result: + return result.text + return None + + def add(self, event: dict) -> None: + self.events.append(event) + kind = event["type"] + if kind == "stdout": + self.stdout.append(event["text"]) + elif kind == "stderr": + self.stderr.append(event["text"]) + elif kind == "result": + self.results.append(Result({k: v for k, v in event.items() if k != "type"})) + elif kind == "error": + self.error = ExecutionError( + name=event["name"], value=event["value"], traceback=event["traceback"] + ) + elif kind == "number_of_executions": + self.execution_count = event["execution_count"] + elif kind == "end_of_execution": + self.completed = True + + @classmethod + def from_events(cls, events) -> "Execution": + execution = cls() + for event in events: + execution.add(event) + return execution + + +def _headers( + envd_access_token: Optional[str], traffic_access_token: Optional[str] +) -> dict[str, str]: + headers = {} + if envd_access_token: + headers["X-Access-Token"] = envd_access_token + if traffic_access_token: + headers["E2B-Traffic-Access-Token"] = traffic_access_token + return headers + + +def _execute_body( + code: str, + language: Optional[str], + context_id: Optional[str], + envs: Optional[dict[str, str]], +) -> dict: + return { + "code": textwrap.dedent(code), + "language": language, + "context_id": context_id, + "env_vars": envs, + } + + +def _is_readiness_error(error: Exception) -> bool: + return isinstance(error, CodeInterpreterError) and error.status_code == 500 + + +class CodeInterpreter: + def __init__( + self, + base_url: str, + *, + envd_access_token: Optional[str] = None, + traffic_access_token: Optional[str] = None, + ): + self.base_url = base_url + self._client = httpx.Client( + base_url=base_url, + headers=_headers(envd_access_token, traffic_access_token), + timeout=DEFAULT_REQUEST_TIMEOUT, + ) + + def close(self) -> None: + self._client.close() + + def health(self, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> httpx.Response: + return self._client.get("/health", timeout=timeout) + + def stream_code( + self, + code: str, + *, + language: Optional[str] = None, + context_id: Optional[str] = None, + envs: Optional[dict[str, str]] = None, + timeout: float = DEFAULT_EXECUTION_TIMEOUT, + ) -> Iterator[dict]: + with self._client.stream( + "POST", + "/execute", + json=_execute_body(code, language, context_id, envs), + timeout=httpx.Timeout(timeout, connect=DEFAULT_REQUEST_TIMEOUT), + ) as response: + if response.status_code >= 400: + response.read() + raise CodeInterpreterError(response) + for line in response.iter_lines(): + if line: + yield json.loads(line) + + def run_code( + self, + code: str, + *, + language: Optional[str] = None, + context_id: Optional[str] = None, + envs: Optional[dict[str, str]] = None, + timeout: float = DEFAULT_EXECUTION_TIMEOUT, + ) -> Execution: + return Execution.from_events( + self.stream_code( + code, + language=language, + context_id=context_id, + envs=envs, + timeout=timeout, + ) + ) + + def wait_for_kernel(self, language: str) -> None: + for attempt in range(KERNEL_READINESS_ATTEMPTS): + try: + self.run_code("1", language=language) + return + except CodeInterpreterError as error: + if ( + not _is_readiness_error(error) + or attempt == KERNEL_READINESS_ATTEMPTS - 1 + ): + raise + + def _check(self, response: httpx.Response) -> httpx.Response: + if response.status_code >= 400: + raise CodeInterpreterError(response) + return response + + def create_context( + self, *, language: Optional[str] = None, cwd: Optional[str] = None + ) -> dict: + body = {} + if language is not None: + body["language"] = language + if cwd is not None: + body["cwd"] = cwd + return self._check(self._client.post("/contexts", json=body)).json() + + def list_contexts(self) -> list[dict]: + return self._check(self._client.get("/contexts")).json() + + def restart_context(self, context_id: str) -> None: + self._check(self._client.post(f"/contexts/{context_id}/restart")) + + def remove_context(self, context_id: str) -> None: + self._check(self._client.delete(f"/contexts/{context_id}")) + + +class AsyncCodeInterpreter: + def __init__( + self, + base_url: str, + *, + envd_access_token: Optional[str] = None, + traffic_access_token: Optional[str] = None, + ): + self.base_url = base_url + self._client = httpx.AsyncClient( + base_url=base_url, + headers=_headers(envd_access_token, traffic_access_token), + timeout=DEFAULT_REQUEST_TIMEOUT, + ) + + async def aclose(self) -> None: + await self._client.aclose() + + async def health(self, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> httpx.Response: + return await self._client.get("/health", timeout=timeout) + + async def stream_code( + self, + code: str, + *, + language: Optional[str] = None, + context_id: Optional[str] = None, + envs: Optional[dict[str, str]] = None, + timeout: float = DEFAULT_EXECUTION_TIMEOUT, + ) -> AsyncIterator[dict]: + async with self._client.stream( + "POST", + "/execute", + json=_execute_body(code, language, context_id, envs), + timeout=httpx.Timeout(timeout, connect=DEFAULT_REQUEST_TIMEOUT), + ) as response: + if response.status_code >= 400: + await response.aread() + raise CodeInterpreterError(response) + async for line in response.aiter_lines(): + if line: + yield json.loads(line) + + async def run_code( + self, + code: str, + *, + language: Optional[str] = None, + context_id: Optional[str] = None, + envs: Optional[dict[str, str]] = None, + timeout: float = DEFAULT_EXECUTION_TIMEOUT, + ) -> Execution: + execution = Execution() + async for event in self.stream_code( + code, language=language, context_id=context_id, envs=envs, timeout=timeout + ): + execution.add(event) + return execution + + async def wait_for_kernel(self, language: str) -> None: + for attempt in range(KERNEL_READINESS_ATTEMPTS): + try: + await self.run_code("1", language=language) + return + except CodeInterpreterError as error: + if ( + not _is_readiness_error(error) + or attempt == KERNEL_READINESS_ATTEMPTS - 1 + ): + raise diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 00000000..e8762061 --- /dev/null +++ b/tests/package.json @@ -0,0 +1,11 @@ +{ + "name": "@e2b/code-interpreter-tests", + "private": true, + "version": "0.0.0", + "scripts": { + "test": "uv run pytest --verbose", + "pretest": "uv sync --locked", + "lint": "uv run ruff check .", + "format": "uv run ruff format ." + } +} diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 00000000..1fd6edb2 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "e2b-code-interpreter-tests" +version = "0.0.0" +description = "HTTP-level test suite for the E2B Code Interpreter template" +requires-python = ">=3.10" +dependencies = [ + "e2b>=2.32.0,<3", + "httpx>=0.28.1,<1", + "pytest>=9.0.3,<10", + "pytest-asyncio>=1.3.0,<2", + "pytest-dotenv>=0.5.2,<0.6", + "pytest-xdist>=3.6.1,<4", + "python-dotenv>=1.2.1,<2", + "ruff>=0.11.12,<0.12", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +addopts = ["--import-mode=importlib", "--numprocesses=2"] +pythonpath = ["."] +markers = [ + "skip_debug: skip when running against a local template server (E2B_DEBUG=true)", +] diff --git a/tests/test_async.py b/tests/test_async.py new file mode 100644 index 00000000..975e0947 --- /dev/null +++ b/tests/test_async.py @@ -0,0 +1,46 @@ +import asyncio + +import httpx +import pytest + +from harness import AsyncCodeInterpreter + + +async def test_async_basic(async_client: AsyncCodeInterpreter): + execution = await async_client.run_code("x =1; x") + assert execution.text == "1" + + +async def test_async_streaming(async_client: AsyncCodeInterpreter): + events = [event async for event in async_client.stream_code("print(1); 2")] + + assert [event["type"] for event in events] == [ + "number_of_executions", + "stdout", + "result", + "end_of_execution", + ] + assert events[1]["text"] == "1\n" + assert events[2]["text"] == "2" + + +async def test_async_concurrent_executions(async_client: AsyncCodeInterpreter): + executions = await asyncio.gather( + async_client.run_code("import time; time.sleep(1); 'python'"), + async_client.run_code( + "await new Promise(r => setTimeout(r, 1000)); 'js'", language="js" + ), + ) + + assert executions[0].text == "python" + assert executions[1].text == "js" + + +async def test_async_interrupt(async_client: AsyncCodeInterpreter): + with pytest.raises(httpx.TimeoutException): + await async_client.run_code("import time; time.sleep(300)", timeout=3) + + await asyncio.sleep(5) + + execution = await async_client.run_code("1 + 1", timeout=10) + assert execution.text == "2" diff --git a/tests/test_basic.py b/tests/test_basic.py new file mode 100644 index 00000000..62d3d3d6 --- /dev/null +++ b/tests/test_basic.py @@ -0,0 +1,75 @@ +import pytest +from e2b import Sandbox + +from conftest import make_client +from harness import CodeInterpreter + + +def test_health(client: CodeInterpreter): + response = client.health() + assert response.status_code == 200 + assert response.json() == "OK" + + +def test_basic(client: CodeInterpreter): + execution = client.run_code("x =1; x") + assert execution.text == "1" + assert execution.completed + + +def test_stateful(client: CodeInterpreter): + client.run_code("test_stateful = 1") + + execution = client.run_code("test_stateful+=1; test_stateful") + assert execution.text == "2" + + +def test_stdout(client: CodeInterpreter): + execution = client.run_code("print('Hello from e2b')") + assert execution.stdout == ["Hello from e2b\n"] + + +def test_stderr(client: CodeInterpreter): + execution = client.run_code( + 'import sys;print("This is an error message", file=sys.stderr)' + ) + assert execution.stderr == ["This is an error message\n"] + + +def test_error(client: CodeInterpreter): + execution = client.run_code("xyz") + assert execution.error is not None + assert execution.error.name == "NameError" + assert execution.error.value == "name 'xyz' is not defined" + assert "NameError" in execution.error.traceback + + +def test_bash_magic(client: CodeInterpreter): + execution = client.run_code("!pwd") + assert "".join(execution.stdout).strip() == "/home/user" + + +@pytest.mark.skip_debug +def test_execution_count(client: CodeInterpreter): + client.run_code("echo 'E2B is awesome!'") + execution = client.run_code("!pwd") + assert execution.execution_count == 2 + + +@pytest.mark.skip_debug +def test_secure_access(client_factory): + client = client_factory(allow_public_traffic=False) + execution = client.run_code("x =1; x") + assert execution.text == "1" + + +@pytest.mark.skip_debug +def test_reconnected_sandbox_keeps_state(sandbox: Sandbox): + first = make_client(sandbox) + first.run_code("x = 1") + first.close() + + second = make_client(Sandbox.connect(sandbox.sandbox_id)) + execution = second.run_code("x") + second.close() + assert execution.text == "1" diff --git a/tests/test_contexts.py b/tests/test_contexts.py new file mode 100644 index 00000000..8c2c4764 --- /dev/null +++ b/tests/test_contexts.py @@ -0,0 +1,118 @@ +import pytest + +from harness import CodeInterpreter, CodeInterpreterError + + +def test_create_context_with_no_options(client: CodeInterpreter): + context = client.create_context() + + assert context["language"] == "python" + assert context["cwd"] == "/home/user" + assert client.list_contexts()[-1] == context + + +def test_create_context_with_options(client: CodeInterpreter): + context = client.create_context(language="python", cwd="/root") + + assert context["language"] == "python" + assert context["cwd"] == "/root" + assert client.list_contexts()[-1] == context + + +@pytest.mark.parametrize( + ("alias", "language"), [("js", "javascript"), ("ts", "typescript")] +) +def test_create_context_normalizes_language_alias( + client: CodeInterpreter, alias: str, language: str +): + context = client.create_context(language=alias) + assert context["language"] == language + + +def test_remove_context(client: CodeInterpreter): + context = client.create_context() + + client.remove_context(context["id"]) + + assert context["id"] not in [ctx["id"] for ctx in client.list_contexts()] + + +def test_list_contexts(client: CodeInterpreter): + languages = [context["language"] for context in client.list_contexts()] + assert "python" in languages + assert "javascript" in languages + + +def test_restart_context(client: CodeInterpreter): + context = client.create_context() + + client.run_code("x = 1", context_id=context["id"]) + client.restart_context(context["id"]) + + execution = client.run_code("x", context_id=context["id"]) + assert execution.error is not None + assert execution.error.name == "NameError" + assert execution.error.value == "name 'x' is not defined" + + +def test_independence_of_contexts(client: CodeInterpreter): + context = client.create_context() + client.run_code("x = 1") + + execution = client.run_code("x", context_id=context["id"]) + assert execution.error is not None + assert execution.error.value == "name 'x' is not defined" + + +def test_pass_context_and_language(client: CodeInterpreter): + context = client.create_context(language="python") + + with pytest.raises(CodeInterpreterError) as error: + client.run_code( + "console.log('Hello, World!')", language="js", context_id=context["id"] + ) + + assert error.value.status_code == 400 + assert "Only one of context_id or language" in error.value.body + + +def test_unknown_context(client: CodeInterpreter): + with pytest.raises(CodeInterpreterError) as error: + client.run_code("1", context_id="does-not-exist") + + assert error.value.status_code == 404 + + +def test_restart_unknown_context(client: CodeInterpreter): + with pytest.raises(CodeInterpreterError) as error: + client.restart_context("does-not-exist") + + assert error.value.status_code == 404 + + +def test_remove_unknown_context(client: CodeInterpreter): + with pytest.raises(CodeInterpreterError) as error: + client.remove_context("does-not-exist") + + assert error.value.status_code == 404 + + +@pytest.mark.skip_debug +def test_contexts_secure_traffic(client_factory): + client = client_factory(allow_public_traffic=False) + + languages = [context["language"] for context in client.list_contexts()] + assert "python" in languages + assert "javascript" in languages + + context = client.create_context() + assert client.list_contexts()[-1] == context + + client.run_code("x = 1", context_id=context["id"]) + client.restart_context(context["id"]) + execution = client.run_code("x", context_id=context["id"]) + assert execution.error is not None + assert execution.error.name == "NameError" + + client.remove_context(context["id"]) + assert context["id"] not in [ctx["id"] for ctx in client.list_contexts()] diff --git a/tests/test_cwd.py b/tests/test_cwd.py new file mode 100644 index 00000000..ba5274a2 --- /dev/null +++ b/tests/test_cwd.py @@ -0,0 +1,43 @@ +import pytest + +from harness import CodeInterpreter + +pytestmark = pytest.mark.skip_debug + + +def test_cwd_python(client: CodeInterpreter): + execution = client.run_code("from pathlib import Path; print(Path.cwd())") + assert "".join(execution.stdout).strip() == "/home/user" + + +def test_cwd_javascript(client: CodeInterpreter): + execution = client.run_code("process.cwd()", language="js") + assert execution.text == "/home/user" + + +def test_cwd_typescript(client: CodeInterpreter): + execution = client.run_code("process.cwd()", language="ts") + assert execution.text == "/home/user" + + +def test_cwd_r(client: CodeInterpreter): + client.wait_for_kernel("r") + execution = client.run_code("getwd()", language="r") + assert execution.results[0].text.strip() == '[1] "/home/user"' + + +def test_cwd_java(client: CodeInterpreter): + client.wait_for_kernel("java") + execution = client.run_code('System.getProperty("user.dir")', language="java") + assert execution.results[0].text.strip() == "/home/user" + + +def test_cwd_bash(client: CodeInterpreter): + execution = client.run_code("pwd", language="bash") + assert "".join(execution.stdout).strip() == "/home/user" + + +def test_custom_cwd_context(client: CodeInterpreter): + context = client.create_context(language="python", cwd="/tmp") + execution = client.run_code("import os; os.getcwd()", context_id=context["id"]) + assert execution.text == "/tmp" diff --git a/tests/test_interrupt.py b/tests/test_interrupt.py new file mode 100644 index 00000000..cda1de46 --- /dev/null +++ b/tests/test_interrupt.py @@ -0,0 +1,20 @@ +import time + +import httpx +import pytest + +from harness import CodeInterpreter + + +def test_subsequent_execution_works_after_client_timeout(client: CodeInterpreter): + # Start a long-running execution with a short client timeout. + # Closing the connection should trigger the server to interrupt the + # kernel (#213) instead of leaving it busy. + with pytest.raises(httpx.TimeoutException): + client.run_code("import time; time.sleep(300)", timeout=3) + + # Give the server time to detect the disconnect and interrupt the kernel. + time.sleep(5) + + execution = client.run_code("1 + 1", timeout=10) + assert execution.text == "2" diff --git a/tests/test_jupyter_socket.py b/tests/test_jupyter_socket.py new file mode 100644 index 00000000..ae204009 --- /dev/null +++ b/tests/test_jupyter_socket.py @@ -0,0 +1,42 @@ +import pytest +from e2b import CommandExitException, Sandbox + +from conftest import make_client + +pytestmark = pytest.mark.skip_debug + +SOCKET_DIR = "/run/e2b-jupyter" +SOCKET_PATH = f"{SOCKET_DIR}/server.sock" +CURL_STATUS = ( + f"curl -s -o /dev/null -w '%{{http_code}}' --unix-socket {SOCKET_PATH} " + "http://localhost/api/status" +) + + +def test_socket_is_private_to_root(sandbox: Sandbox): + result = sandbox.commands.run( + f"stat -c '%a %U' {SOCKET_DIR} {SOCKET_PATH}", user="root" + ) + assert result.stdout.split("\n")[:2] == ["700 root", "600 root"] + + +def test_jupyter_api_is_served_over_the_socket(sandbox: Sandbox): + assert sandbox.commands.run(CURL_STATUS, user="root").stdout == "200" + + +def test_socket_is_not_reachable_by_user(sandbox: Sandbox): + with pytest.raises(CommandExitException) as error: + sandbox.commands.run(CURL_STATUS, user="user") + assert error.value.exit_code == 7 # curl: failed to connect + + +def test_jupyter_has_no_tcp_listener(sandbox: Sandbox): + listeners = sandbox.commands.run("ss -ltnH", user="root").stdout + assert ":8888" not in listeners + + # The kernel runs inside the sandbox, so this is the loopback view + # jupyter-server's TCP port would be exposed on. + execution = make_client(sandbox).run_code( + "import socket\nsocket.socket().connect_ex(('127.0.0.1', 8888)) != 0" + ) + assert execution.text == "True" diff --git a/tests/test_kernels.py b/tests/test_kernels.py new file mode 100644 index 00000000..bc3893f9 --- /dev/null +++ b/tests/test_kernels.py @@ -0,0 +1,52 @@ +import pytest + +from harness import CodeInterpreter + + +def test_js_kernel(client: CodeInterpreter): + execution = client.run_code("console.log('Hello, World!')", language="js") + assert execution.stdout == ["Hello, World!\n"] + + +def test_js_esm_imports(client: CodeInterpreter): + execution = client.run_code( + """ + import { readFileSync } from 'fs' + console.log(typeof readFileSync) + """, + language="js", + ) + assert execution.stdout == ["function\n"] + + +def test_js_top_level_await(client: CodeInterpreter): + execution = client.run_code("await Promise.resolve('Hello World!')", language="js") + assert execution.text == "Hello World!" + + +@pytest.mark.skip_debug +def test_ts_kernel(client: CodeInterpreter): + execution = client.run_code( + "const message: string = 'Hello, World!'; console.log(message)", language="ts" + ) + assert execution.stdout == ["Hello, World!\n"] + + +@pytest.mark.skip_debug +def test_r_kernel(client: CodeInterpreter): + client.wait_for_kernel("r") + execution = client.run_code('print("Hello, World!")', language="r") + assert execution.stdout == ['[1] "Hello, World!"\n'] + + +@pytest.mark.skip_debug +def test_java_kernel(client: CodeInterpreter): + client.wait_for_kernel("java") + execution = client.run_code('System.out.println("Hello, World!")', language="java") + assert execution.stdout[0] == "Hello, World!" + + +@pytest.mark.skip_debug +def test_bash_kernel(client: CodeInterpreter): + execution = client.run_code("echo 'Hello, World!'", language="bash") + assert execution.stdout == ["Hello, World!\n"] diff --git a/tests/test_killed.py b/tests/test_killed.py new file mode 100644 index 00000000..3e9d3b3b --- /dev/null +++ b/tests/test_killed.py @@ -0,0 +1,42 @@ +import threading +import time + +import httpx +import pytest +from e2b import Sandbox + +from conftest import make_client +from harness import Execution + + +@pytest.mark.skip_debug +def test_execution_ends_when_sandbox_is_killed(sandbox: Sandbox): + client = make_client(sandbox) + kill_error: list[BaseException] = [] + + def kill(): + try: + sandbox.kill() + except BaseException as error: # surfaced below, Timer swallows it + kill_error.append(error) + + timer = threading.Timer(2.0, kill) + timer.start() + + execution = Execution() + started = time.monotonic() + try: + # The sleep is far longer than the kill delay, so the only thing that + # can end the request is the sandbox being killed; the client timeout + # just keeps a failed kill from hanging the test. + with pytest.raises(httpx.HTTPError): + for event in client.stream_code("import time; time.sleep(300)", timeout=60): + execution.add(event) + finally: + timer.join() + client.close() + + assert not kill_error, kill_error + assert time.monotonic() - started < 60 + assert not execution.completed + assert not sandbox.is_running() diff --git a/tests/test_results.py b/tests/test_results.py new file mode 100644 index 00000000..9faa8502 --- /dev/null +++ b/tests/test_results.py @@ -0,0 +1,111 @@ +from harness import CodeInterpreter + + +def test_display_data(client: CodeInterpreter): + execution = client.run_code( + """ + import matplotlib.pyplot as plt + import numpy as np + + x = np.linspace(0, 20, 100) + y = np.sin(x) + + plt.plot(x, y) + plt.show() + """ + ) + + result = execution.results[0] + assert result.png + assert result.text + assert not result.extra + + +def test_data(client: CodeInterpreter): + execution = client.run_code( + """ + import pandas as pd + pd.DataFrame({"a": [1, 2, 3]}) + """ + ) + + result = execution.results[0] + assert result.data + assert "a" in result.data + assert len(result.data["a"]) == 3 + + +def test_custom_repr_object(client: CodeInterpreter): + execution = client.run_code( + """ + from IPython.display import display + + display({'text/latex': r'\\text{CustomReprObject}'}, raw=True) + """ + ) + + assert execution.results[0].formats() == ["latex"] + assert execution.results[0].latex == r"\text{CustomReprObject}" + + +def test_html_result(client: CodeInterpreter): + execution = client.run_code( + """ + from IPython.display import HTML + HTML('bold') + """ + ) + + result = execution.results[0] + assert result.html == "bold" + assert result.is_main_result is True + + +def test_show_image(client: CodeInterpreter): + execution = client.run_code( + """ + import numpy + from PIL import Image + + imarray = numpy.random.rand(16,16,3) * 255 + image = Image.fromarray(imarray.astype('uint8')).convert('RGBA') + + image.show() + print("Image shown.") + """ + ) + + assert execution.results[0].png + + +def test_image_as_last_command(client: CodeInterpreter): + execution = client.run_code( + """ + import numpy + from PIL import Image + + imarray = numpy.random.rand(16,16,3) * 255 + image = Image.fromarray(imarray.astype('uint8')).convert('RGBA') + + image + """ + ) + + assert execution.results[0].png + + +def test_get_image_on_save(client: CodeInterpreter): + execution = client.run_code( + """ + import numpy + from PIL import Image + + imarray = numpy.random.rand(16,16,3) * 255 + image = Image.fromarray(imarray.astype('uint8')).convert('RGBA') + + image.save("test.png") + print("Image saved.") + """ + ) + + assert execution.results[0].png diff --git a/tests/test_streaming.py b/tests/test_streaming.py new file mode 100644 index 00000000..c209461e --- /dev/null +++ b/tests/test_streaming.py @@ -0,0 +1,70 @@ +from harness import CodeInterpreter + + +def _types(events: list[dict]) -> list[str]: + return [event["type"] for event in events] + + +def test_streaming_stdout(client: CodeInterpreter): + events = list(client.stream_code("print(1)")) + + stdout = [event for event in events if event["type"] == "stdout"] + assert len(stdout) == 1 + assert stdout[0]["text"] == "1\n" + assert "timestamp" in stdout[0] + assert events[-1] == {"type": "end_of_execution"} + + +def test_streaming_stderr(client: CodeInterpreter): + events = list(client.stream_code("import sys;print(1, file=sys.stderr)")) + + stderr = [event for event in events if event["type"] == "stderr"] + assert len(stderr) == 1 + assert stderr[0]["text"] == "1\n" + + +def test_streaming_result(client: CodeInterpreter): + code = """ + import matplotlib.pyplot as plt + import numpy as np + + x = np.linspace(0, 20, 100) + y = np.sin(x) + + plt.plot(x, y) + plt.show() + + x + """ + + events = list(client.stream_code(code)) + + results = [event for event in events if event["type"] == "result"] + assert len(results) == 2 + assert results[0]["is_main_result"] is False + assert results[1]["is_main_result"] is True + + +def test_event_order(client: CodeInterpreter): + events = list(client.stream_code("print('a'); 1")) + + assert _types(events) == [ + "number_of_executions", + "stdout", + "result", + "end_of_execution", + ] + + +def test_stdout_streams_before_execution_finishes(client: CodeInterpreter): + import time + + arrival = [] + for event in client.stream_code( + "import time; print('a'); time.sleep(2); print('b')" + ): + if event["type"] == "stdout": + arrival.append((event["text"], time.monotonic())) + + assert [text for text, _ in arrival] == ["a\n", "b\n"] + assert arrival[1][1] - arrival[0][1] >= 1.5 diff --git a/tests/test_systemd.py b/tests/test_systemd.py new file mode 100644 index 00000000..16780745 --- /dev/null +++ b/tests/test_systemd.py @@ -0,0 +1,45 @@ +import time + +import httpx +import pytest +from e2b import Sandbox + +from conftest import make_client +from harness import CodeInterpreter + +pytestmark = pytest.mark.skip_debug + + +def wait_for_health(client: CodeInterpreter, max_retries=10, interval_ms=100) -> bool: + for _ in range(max_retries): + try: + if client.health(timeout=5).status_code == 200: + return True + except httpx.HTTPError: + pass + time.sleep(interval_ms / 1000) + return False + + +def kill_process(sandbox: Sandbox, pattern: str) -> None: + # The command handle may get killed too (killing jupyter cascades to the + # code-interpreter service), so errors are ignored. + try: + sandbox.commands.run(f"kill -9 $(pgrep -f '{pattern}')", user="root") + except Exception: + pass + + +@pytest.mark.parametrize("pattern", ["jupyter server", "uvicorn main:app"]) +def test_restart_after_kill(sandbox: Sandbox, pattern: str): + client = make_client(sandbox) + assert wait_for_health(client) + + kill_process(sandbox, pattern) + + # Wait for systemd to restart the service(s) and health to come back + assert wait_for_health(client, 60, 500) + + execution = client.run_code("x = 1; x") + assert execution.text == "1" + client.close() diff --git a/tests/uv.lock b/tests/uv.lock new file mode 100644 index 00000000..af6f090c --- /dev/null +++ b/tests/uv.lock @@ -0,0 +1,602 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "bracex" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/01/5f394b8bcd6e5b92f73130990960423bbb19711f906bd9fe9ea5557c667c/bracex-3.0.1.tar.gz", hash = "sha256:4e38e32392e4a4780fe15d644bfc7c8514057cfc3861e060b11814ce829c25e4", size = 44019, upload-time = "2026-07-20T13:43:00.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/8f/6f7273a7adb8d73fc8d21ede4376a3e475e52f98435c6007f69100dec8ca/bracex-3.0.1-py3-none-any.whl", hash = "sha256:6523ad83aeb5098a4ee597cff0f964442ff74e460bd3fafaffab6a013ff2288c", size = 11940, upload-time = "2026-07-20T13:42:59.268Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[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, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "connectrpc" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf-py" }, + { name = "pyqwest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/b5/63e14ab9d4d4cc58818562db4120a35fa7454e3035633da41bdc6b712abd/connectrpc-0.11.1.tar.gz", hash = "sha256:18277f7838847b4271ca38d40c7d2387b5a2ea6a29f240689c19e1ec84aaff66", size = 46222, upload-time = "2026-07-15T06:33:31.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/ac/aa647675812cd075f2cb9acb00d62f4f2cbcbc6736049a62342b7371ce5b/connectrpc-0.11.1-py3-none-any.whl", hash = "sha256:8a52e2e92a485fa9681c1101a79a5ebeb31807e3ea3d5aabd41f484a6398bc7b", size = 64991, upload-time = "2026-07-15T06:33:29.396Z" }, +] + +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + +[[package]] +name = "e2b" +version = "2.53.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "connectrpc" }, + { name = "dockerfile-parse" }, + { name = "h2" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf-py" }, + { name = "pyqwest" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/84/a5798cbcd7e8a3d4d4b913b9caebd7ffcfd07f92e7ddef36c6972ce21f9c/e2b-2.53.1.tar.gz", hash = "sha256:c58fce7de1104f82b5402d1ec347b28535bfe34f5461ad677a0a752303f8d24b", size = 238284, upload-time = "2026-10-06T20:17:48.483Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/71/25fe260524bf9a7754781099ae7e30321e298d92f116373ef2e5d9e6d689/e2b-2.53.1-py3-none-any.whl", hash = "sha256:12ee0324aef2b6f064fe9037db52ddc3fcc6bec0e9c2064766b5e4a2b7c46d51", size = 400012, upload-time = "2026-10-06T20:17:46.254Z" }, +] + +[[package]] +name = "e2b-code-interpreter-tests" +version = "0.0.0" +source = { virtual = "." } +dependencies = [ + { name = "e2b" }, + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-dotenv" }, + { name = "pytest-xdist" }, + { name = "python-dotenv" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "e2b", specifier = ">=2.32.0,<3" }, + { name = "httpx", specifier = ">=0.28.1,<1" }, + { name = "pytest", specifier = ">=9.0.3,<10" }, + { name = "pytest-asyncio", specifier = ">=1.3.0,<2" }, + { name = "pytest-dotenv", specifier = ">=0.5.2,<0.6" }, + { name = "pytest-xdist", specifier = ">=3.6.1,<4" }, + { name = "python-dotenv", specifier = ">=1.2.1,<2" }, + { name = "ruff", specifier = ">=0.11.12,<0.12" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "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, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[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, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" }, +] + +[[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, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[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, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[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, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf-py" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf-py-ext", marker = "(platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/ed/02fd902d9c51b7ff53dfc9a745eb11490722edfd30073af889e171f07b8e/protobuf_py-0.1.1.tar.gz", hash = "sha256:6bd08ac4d8f1661965bbe2685429d79043704cdd1ee720a7a89617331742240b", size = 133525, upload-time = "2026-06-24T19:02:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/00/1b3775aca1c70e3007e06ef5996f6bb9b3a32341eb0cce3ffb6effad8dec/protobuf_py-0.1.1-py3-none-any.whl", hash = "sha256:efc4f50f275ed6dae10a1f30bb81ad1a75368557b3ff22a532b7a472050368f1", size = 181656, upload-time = "2026-06-24T19:01:29.556Z" }, +] + +[[package]] +name = "protobuf-py-ext" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/05/6dc9ccff1e8159eb9a144e6d3c4acfd2211cd4fcd20c34fe9155d17d6a7f/protobuf_py_ext-0.1.1.tar.gz", hash = "sha256:e85bfdfdb3ed50634db8ccc7429dd9286520109489c735463971a418707b4fef", size = 31912, upload-time = "2026-06-24T19:02:16.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/70/2b5d62a60a2e0d88ecde1ae98db3132bcd2672fb39c8d581b82b34ac0bd8/protobuf_py_ext-0.1.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:310039e03cb15181781a0b78017419f6d4ee302e988c3c70b87f1facdf05532d", size = 306008, upload-time = "2026-06-24T19:01:31.399Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d6/73c06bb4cac2e04c0adc154965fe8b6520224bd737fd7e73bba405056321/protobuf_py_ext-0.1.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ec8348d1ba045f79b2fedd14e40cca36f2a41b52f2c4fdf55a60c58add2353", size = 314769, upload-time = "2026-06-24T19:01:32.89Z" }, + { url = "https://files.pythonhosted.org/packages/18/c5/e4e6bc6096b66d1c82639a1b501147f16ee65d32567304b987d002e2c666/protobuf_py_ext-0.1.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:182798e4861aba72d05855bd06febe4926aa7265e6f444a1b8af5252beee4f7e", size = 328122, upload-time = "2026-06-24T19:01:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/1d792a40d0f0a0f914f1dfa8bb5e9573ca0ecd5fe5cecf80d77193212abd/protobuf_py_ext-0.1.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9eb25c3a329c0551cc86b209a5e5d8ecb8d834b9924a3aa019377853a703b6d3", size = 492338, upload-time = "2026-06-24T19:01:36.103Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/5ad329223c905e5c530cae38ae24cde3584d8ab7e09457f2a01afce80e60/protobuf_py_ext-0.1.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:aaecbde82bef10c7c40578cbb61b7a19896bf7fa450972050a3bb302acb7d5d6", size = 541578, upload-time = "2026-06-24T19:01:37.481Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c7/01bd8a8bfe2df4b07aafac12ccfb7b7ebe2303f65296a9e02fcafa28ff3e/protobuf_py_ext-0.1.1-cp310-abi3-win_amd64.whl", hash = "sha256:6b0c615c48e95acc53cf33e9310eeaff8b30d2d7555bf93e7bca8fb4f40e9a5c", size = 251319, upload-time = "2026-06-24T19:01:38.946Z" }, + { url = "https://files.pythonhosted.org/packages/24/dc/914065538b5db54b6a920b5af38c1ef142252ea1eea711820435fa259fac/protobuf_py_ext-0.1.1-cp310-abi3-win_arm64.whl", hash = "sha256:72956cd0af5dee24b41c6f5ba5e42622d17e6d555002b5efc1634e27a1446de2", size = 241635, upload-time = "2026-06-24T19:01:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/13/a5/0b5d73cab815fd50615cb87a02e04e8332f9e27380c890aba1459cf7e919/protobuf_py_ext-0.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c6f0cd58620f415a3534d195358338f4999a774e12510f91c592b38d13d37388", size = 304903, upload-time = "2026-06-24T19:01:41.796Z" }, + { url = "https://files.pythonhosted.org/packages/37/a9/14c120b9ac36a0e11bb05e482fa6ae322de583a8e1068e4859035c289947/protobuf_py_ext-0.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21572764f625d829604fc4d83635f533f35775f11c6da142345b3f3c8d64bd09", size = 316148, upload-time = "2026-06-24T19:01:43.247Z" }, + { url = "https://files.pythonhosted.org/packages/35/54/7c00a1a9783c3ba74f6b2f5d2f049f09037bbd489c465c09a34f32fb611b/protobuf_py_ext-0.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7f14f00c2678bfbe7ad46f057b9f9938c1677bcf39e405e163e272c6f9814b8", size = 326458, upload-time = "2026-06-24T19:01:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0e/81fa50c0d6c4d664e5be6d0a3c4f2c7edfef87ab12d0bac764abca1f2955/protobuf_py_ext-0.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1610865622e2e27568277ca63d8d2d23dcc55eaa767398865c21539ddf4ce24d", size = 493596, upload-time = "2026-06-24T19:01:46.344Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/3183cf6e4de62846c20549654a1d573dae51ca06aaf7fed06accdfab74f6/protobuf_py_ext-0.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8995efba9476e1ea18ef9873306871dba697308994bc2766558fec3387acc3de", size = 539656, upload-time = "2026-06-24T19:01:48.21Z" }, + { url = "https://files.pythonhosted.org/packages/a4/6c/09841f3dbe7c3d4b3f2779f8f2832ac23fb96ac8ab71674e91af732cf9ff/protobuf_py_ext-0.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba61d49dace8f874361583030a7c48139b42eb37c9ffbb1e7e8a227a51576f44", size = 305017, upload-time = "2026-06-24T19:01:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/52/43/e450b5e202f6715274acc9acd9f9769908cbdeab861a53c798fcbd467d35/protobuf_py_ext-0.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a9c2f026096ca4c595c89a297067ef371e241d3ff8e1f6d7c779aa8419cdca7", size = 316215, upload-time = "2026-06-24T19:01:51.249Z" }, + { url = "https://files.pythonhosted.org/packages/97/5c/23e79b35f1b5755b9bdc0c0c9b8cd8abababaef1a1639608d8a96bb61a9d/protobuf_py_ext-0.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf78707a040b9294e5e1ec4a1875f0046acfe52e92150cea27de4e9fc9db39bb", size = 326419, upload-time = "2026-06-24T19:01:52.93Z" }, + { url = "https://files.pythonhosted.org/packages/e8/de/5493de0ec12920a3b1dd72608ce0c1e8cdb7e0eb9e87accaecc5216df29e/protobuf_py_ext-0.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae4373845cbb85bdade3ef5368cc2f4b5f80bf173383afc1ab063f95644e5599", size = 493734, upload-time = "2026-06-24T19:01:54.301Z" }, + { url = "https://files.pythonhosted.org/packages/98/89/31da55b414e6332aad11d858e9a86bd36fbb324f52fa3fc6d8f1c57840a9/protobuf_py_ext-0.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2a4cc478eef7a2acc1daaebcde479a3ac2396d47e6bdc7e776ca4c4147ba8b4c", size = 539703, upload-time = "2026-06-24T19:01:55.707Z" }, + { url = "https://files.pythonhosted.org/packages/1c/71/39f231838ef06476d46ac40dd814894277a732a3688c0a0c994850b3b62f/protobuf_py_ext-0.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:359dccdc1c3eafed2a913c570bb082b8df848a1d27d548ce8385f246a7d68be2", size = 302145, upload-time = "2026-06-24T19:01:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/87/f1/01c81ff5f420600366a0e6a613ce2af20834534d2b3d7523f4f3b4ddb54f/protobuf_py_ext-0.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91c38b4a10a306366443273ee03ca554537a0965bf05b7ada8e7dbdee04cc93e", size = 314541, upload-time = "2026-06-24T19:01:58.745Z" }, + { url = "https://files.pythonhosted.org/packages/86/d8/1cbf5a0298ceddc0cdbb28bf1ecaf8bd3cff54ed4ced6a1392d5226172fc/protobuf_py_ext-0.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79eee3bcbb289d6ea114eb8fe3a1469c5b59bf53e207238469f567d9c53ba56f", size = 325092, upload-time = "2026-06-24T19:02:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/af/23/8b1694616044cbfec2d18d4c6fffb03e05089c8bdb5970c6bafd60cc7fd5/protobuf_py_ext-0.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:10992141a8282a71ac3e3530d1a489efb27618d84000b9a47918cf70e5816d9b", size = 491684, upload-time = "2026-06-24T19:02:01.862Z" }, + { url = "https://files.pythonhosted.org/packages/49/d8/99997a7a6cf6989c944d9183c5deb6a045c27460add0316c7b34763891b3/protobuf_py_ext-0.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4411ffe0e06a774b83c5c71c546ce097640a25f596c45f95f53d3e3148e3f22d", size = 538048, upload-time = "2026-06-24T19:02:03.362Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/9e99ecbb68e1d5b46016d821eb1cbba9a91e6b50e71e75faf0c1e6189f0d/protobuf_py_ext-0.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55a17d80ea419501ff221a6627523f38a431bb33e6aa3de81ae3a7f271c49c75", size = 296337, upload-time = "2026-06-24T19:02:04.787Z" }, + { url = "https://files.pythonhosted.org/packages/af/f2/305338a28225fb54b28d6c3f5948109b9088b329a2b6f77ca610c2bdcd34/protobuf_py_ext-0.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbb518b5638403ceaa08ac4fc7dac626f45ed9b856b3517de3906cf3de4d632", size = 308961, upload-time = "2026-06-24T19:02:06.185Z" }, + { url = "https://files.pythonhosted.org/packages/60/f9/416103c93677ff2ea407704ea64fa6de9e700dc030c48360a182d91ce373/protobuf_py_ext-0.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a4adc65ab6a5e4885c67fc808bcacb83d755d05b566d312a0e10c2a873f2ad4", size = 321895, upload-time = "2026-06-24T19:02:07.714Z" }, + { url = "https://files.pythonhosted.org/packages/5a/83/eb3e81bb2f83834b7deff4cee2d56eeb1adcbc847492a56b7f910ab257db/protobuf_py_ext-0.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e97f45c49676efacfb8e95bfcb1a002bc337e618a6780be1e55df2ba5ebd2f1e", size = 486091, upload-time = "2026-06-24T19:02:09.243Z" }, + { url = "https://files.pythonhosted.org/packages/99/96/bd88fca38556b3105e4dd41d6a176e31dcc583fe979e830aeedd2f8c20ee/protobuf_py_ext-0.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:53a6b3590f6aa7f97b8ed60f62fef9b096babfeae82f7283fd3a4c405827d4f8", size = 534582, upload-time = "2026-06-24T19:02:11.227Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyqwest" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/ee/0ff9facfa9e7a4f6df2a770d4eaf1ad0f74165da7e8c28e888461f07604c/pyqwest-0.10.0.tar.gz", hash = "sha256:6c1a693be17d57d2c2eca4085e32c2809c53090c16719a907c90ebcf1f40dc01", size = 482248, upload-time = "2026-08-21T06:09:20.656Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/ee/b1a28f57c689606cfd065d8a553841150f7daaa91d20e58dcc2c5ea191f8/pyqwest-0.10.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:aa492d5777dd145a60795ed95d9d4707a3cd1091fdcdfc93a82ac7fdc43ebacd", size = 5261059, upload-time = "2026-08-21T06:08:04.999Z" }, + { url = "https://files.pythonhosted.org/packages/dc/13/9c5046cfd6ef705bde0b620ba8a794335bcabc0839342a2a647f2427b27e/pyqwest-0.10.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:59f3f16628e518c674102e7b5fcff2101bba6abb4f6737ec5fade9b9278e6a53", size = 5134207, upload-time = "2026-08-21T06:08:06.955Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/50021dd88d82d6966ab1c27593ceaee9d1ed62fbe597c40e8dc187cfa5fd/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6e7db305a8318b1f3218053e87501f8f245ca8bd63e948e0282d04bf0883470", size = 5640730, upload-time = "2026-08-21T06:08:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3f/5bf6c32e9e701837a8c47ce6e3ad38978cfec8eb7bc6596181e5f9e1eaeb/pyqwest-0.10.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5c757cfac5f53c8671dcb4850d5fc4c4339ea3e90636331c9318f8e3ddabc06", size = 5561462, upload-time = "2026-08-21T06:08:10.836Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/ca9ba5721461b7ce5cfaac373ab3a1723ddcc434af7430f8bd628da6e623/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:234b3f71e3f314d997c203d8cf829b7117edd041153f9c277d0060ab90134148", size = 5801847, upload-time = "2026-08-21T06:08:12.502Z" }, + { url = "https://files.pythonhosted.org/packages/d2/44/95593919b996a417093f598d887822b9b899e8d025588c9bfaf8c60dd812/pyqwest-0.10.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5637256a0dac0ef57e0eaa02b032014965e4a4c995e1deca1b1b97e6d1765f78", size = 5978692, upload-time = "2026-08-21T06:08:14.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/b2821ce5188457168ebb25d5ff65b1ca1bf27bc6b4a33df4bcc2357e625c/pyqwest-0.10.0-cp310-abi3-win_amd64.whl", hash = "sha256:7ea761937acf3a00d1a7e70e982949d18946e5471d1419266ab3a78bbfa19759", size = 4876627, upload-time = "2026-08-21T06:08:16.084Z" }, + { url = "https://files.pythonhosted.org/packages/86/b4/16ccef1c203fa258ce46a86aefc1a79c13b5f0b8d49627347d90eef25efd/pyqwest-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a21f1f15252a8303623b4f17b9c6de595ace11b3ade07f2adb6d07121e8191aa", size = 5274815, upload-time = "2026-08-21T06:08:17.777Z" }, + { url = "https://files.pythonhosted.org/packages/5f/61/6a87f84f571441ea43279587d4bfcad4543505918ae2b83a1ebdcfa98be5/pyqwest-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eb472c6e5d6833ebfec79db310e426eb17b01ac64c0e2c251bd9192c0d2ee0c5", size = 5123656, upload-time = "2026-08-21T06:08:19.501Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/ab69e581cf9b798b0e169f7b27fd3f8b6f9f1631bd4d3b6e22e5abaf8d8b/pyqwest-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83578e24cccd5e0dc04d60a0af7bfb43325b5f22d03ff74ff79ed0ecf553b50d", size = 5641253, upload-time = "2026-08-21T06:08:21.627Z" }, + { url = "https://files.pythonhosted.org/packages/9f/dd/f1a62eebf8321ace506bd94551a01431f6a45b882225455eae3ea6e8c6d1/pyqwest-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bb511c434f79c641efb5573e5795e56dc972252f4b96e52a9636d4ece5231a4", size = 5567341, upload-time = "2026-08-21T06:08:23.346Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/51d767691973e046887f5e6d96e32142fe296823b163ccba732233a6ef72/pyqwest-0.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1aaccd8a9db9430b2aedb5bad8ead80742cbc056b85c229516c70dc80539f906", size = 5803874, upload-time = "2026-08-21T06:08:25.096Z" }, + { url = "https://files.pythonhosted.org/packages/58/0a/d2834ccc6e59ad110718895cc65ff2a68aa6e010f0ba8fbe42a57ea33c21/pyqwest-0.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:73d9eb438ab4a957a1ce0619d3af8c1c1126bfb9181033b123d792fcf4224531", size = 5981518, upload-time = "2026-08-21T06:08:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/75/f3/274b4c268e9a55fbdb1b3637ac50b5bf42cd3a85d1cfbdc15c602a7b0d9c/pyqwest-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:317a74d633abe3bc5bccabf479e069c515dab9e6a755274b0ccb1d8a5bbfede3", size = 4870638, upload-time = "2026-08-21T06:08:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3d/8b1092f25159bf61a9470ebd35438c669b91ef553a7ee205bdec8006107b/pyqwest-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3978e794b9cfd8eaa500fb5d7aee63bc6172c605efa0abc1f62d85485bc049e1", size = 5273599, upload-time = "2026-08-21T06:08:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/75/10/54a9786123942b124c2afb9562b74e158afec7be40ef0caa0d37f615d379/pyqwest-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:715991fd4f04862cd7a9d7452daabcdbd74dff4dff55eb20c22d60382dc2a4ed", size = 5122920, upload-time = "2026-08-21T06:08:33.025Z" }, + { url = "https://files.pythonhosted.org/packages/a2/5b/6a6bd76f91e068b9a619f62aef9fe5ef201f859ddbb6b0a11ad3875ecdda/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c04798bed79c1dfa0e5b0e30fb137124311083490d44d6dfbe068d3dd254349e", size = 5639577, upload-time = "2026-08-21T06:08:34.896Z" }, + { url = "https://files.pythonhosted.org/packages/68/28/12277a24a8dd74b0a7f124c624d9ed58eccb41e2087ff1087a14d348c778/pyqwest-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35b472877e73dd63fed089c2bc8fa198407f005c8c19e0a93f025ebefde01a81", size = 5565835, upload-time = "2026-08-21T06:08:36.567Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b6/b7da4a3f1fe43600154ae75e91ba7969024d886d60077dd3a1ba8e66d170/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:564ec360b7848b35e009038ffbca00466305a9708ab21829477f64aa8cad4c64", size = 5803078, upload-time = "2026-08-21T06:08:38.362Z" }, + { url = "https://files.pythonhosted.org/packages/0c/6f/0c9ba210f49f232289afaa8f06369c5f135ac786e1ca0cb22243b7f1fe2c/pyqwest-0.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b5c80e88a5967c1cadb3237c450f91a84a3683f8838c8dca96f09fee3612e762", size = 5980431, upload-time = "2026-08-21T06:08:39.967Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f5/306eeed41a3cd3100247e6e442f4345277b70f1d24efe1641181b14839cd/pyqwest-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc3d80b402fb59dbe015e25993ac8147456fb231a4c949f92a89f31315ad50f9", size = 4870198, upload-time = "2026-08-21T06:08:41.687Z" }, + { url = "https://files.pythonhosted.org/packages/64/18/0086a408e7cbf39dab18fa5b7e42c969a98382da5a4e6debe40f05acc6a1/pyqwest-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:23a28beb55fa6d975949bffae4adfb69378f3229bb5cbd71231e95bf66f5b26c", size = 5274542, upload-time = "2026-08-21T06:08:43.323Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/e1b9aaaf7596e4faaa53cefc2efaca4e3cde721e308e6385e366361cfdcc/pyqwest-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e4415ae40b8eedb1713dab14d7f9fecc3f79d26f3206c561087b88b99d5ce24b", size = 5127922, upload-time = "2026-08-21T06:08:45.116Z" }, + { url = "https://files.pythonhosted.org/packages/75/cd/e6d68bb1de5dd26100fcfc878cbd67c402a928774edf1e8ae304c5a84f5b/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14b875d2273212d7fa8e4b755d8d736ffd226b1c707a9c0017dfdc8393a96eca", size = 5645856, upload-time = "2026-08-21T06:08:47.055Z" }, + { url = "https://files.pythonhosted.org/packages/f7/48/8c9f9f0467c41f6a563146d57a52f8f6d60c0cb09d0fd3ef88ad5a1c442f/pyqwest-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5071491e416ea54e3b95bf9ffbed0bd065b093cb96e10a75c3d8f2cbe3c9823", size = 5569831, upload-time = "2026-08-21T06:08:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/16/ac/c85ab70c6c72078d49a82da76b820e46aaf95a3f6fe271dac955ac195d21/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b68b5e68d513a4c63a072f8f40e38015160cf90bfbf7e8ef7c3935ca87e9e022", size = 5806735, upload-time = "2026-08-21T06:08:50.652Z" }, + { url = "https://files.pythonhosted.org/packages/66/db/ad7375b22fb2d0807431dcc9bc2aaf840e374c298cd15071024d5f6dd6d1/pyqwest-0.10.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c48910d27820b9c46fcd001b0fe514a3cf47d4784f59512dcdb8c91c395f82e4", size = 5984786, upload-time = "2026-08-21T06:08:52.377Z" }, + { url = "https://files.pythonhosted.org/packages/17/88/c449a772afe129683fd7acc657cbc7c69bec085dc75b6e8710a50fbb44e7/pyqwest-0.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:d03ba2cd17948b623a6210981d342eb122546d8a8e910ec77511aff4b1acdd00", size = 4872288, upload-time = "2026-08-21T06:08:54.101Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f8/439ffc0ee12cd7d9b57ac07ccea78ad3ba66b0d6817d429dd661d73308c4/pyqwest-0.10.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:07a0eb595f4096232c2d22549b6e4612c1ecada7934e46462c2c37ce14a89cfb", size = 5256823, upload-time = "2026-08-21T06:08:55.681Z" }, + { url = "https://files.pythonhosted.org/packages/df/2b/72ecd27d104d2b4284710194cce796d607674966c3ea66436768e812a66a/pyqwest-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:26401baf7dafc71c8d12d2e8389519d141e6f7c14094d0dd4cf9ec1d3b5555bd", size = 5112624, upload-time = "2026-08-21T06:08:57.366Z" }, + { url = "https://files.pythonhosted.org/packages/a4/24/0fb89c3f7d5a0410fcb7560588b4c741ef24b19195c307d4263f04e75c2b/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5e3c436e041d8873ce5bb0fdcf9f9e86f5604e8f0ef9e03149efebd8cb474f6", size = 5631844, upload-time = "2026-08-21T06:08:59.165Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4f/921d14754a186f0143ad62b50108dd808328e347e98e9dafab3897eeb405/pyqwest-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:09364115761579eabfc79d1e954cdb3ded508dac1903fac7285d4c6f058c683f", size = 5555869, upload-time = "2026-08-21T06:09:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/b8/70/504780417319a626fe9549a7e6f9020a3d448eddf8a09617238a3426e90c/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559674a98a8b1217e1830ecd41c9905bf2b60983c6b8017063dfac199f00727c", size = 5793738, upload-time = "2026-08-21T06:09:03.324Z" }, + { url = "https://files.pythonhosted.org/packages/45/f7/8d0a5b8a3289f4300dc9005ebde75d316f2371a2671617f942036337731a/pyqwest-0.10.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f399a696392fff3db3eef0a18ef65b8a3b8396d129193487d966b8eb11006376", size = 5972889, upload-time = "2026-08-21T06:09:05.2Z" }, + { url = "https://files.pythonhosted.org/packages/89/c4/f4c781e475c451cb5f4762a2f814a1750ef375b8db4db09bb2dcea03c4e5/pyqwest-0.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0f9163d6dd991bf1bf27308ba38ba021af660b15fffa47ebca98e41cf6f00309", size = 4858036, upload-time = "2026-08-21T06:09:06.926Z" }, + { url = "https://files.pythonhosted.org/packages/7a/6f/29f605665f33aab894db8daf11b3b64bdca015fb11135815c53a150191a6/pyqwest-0.10.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cfcc7ba0229baa17831582befb046ace167b368140dae022d0b89b8d586ba12c", size = 5264903, upload-time = "2026-08-21T06:09:09.183Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c8/4ce4b40f21397a6482da910fae18b32c28dcdaddf3498aef2fe38597a9e0/pyqwest-0.10.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eff9ccf427604d34c635954def07b6113d4754f968073eef2df40bdb80b05bf5", size = 5142291, upload-time = "2026-08-21T06:09:10.943Z" }, + { url = "https://files.pythonhosted.org/packages/41/34/7205db9cddc5a2286da459ca013af629e20f09498751c60093b952379add/pyqwest-0.10.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78662158093f9d5c742368f4dd9956aa595f44c6ac0860777c98853ddd5e1610", size = 5648118, upload-time = "2026-08-21T06:09:12.616Z" }, + { url = "https://files.pythonhosted.org/packages/1e/94/75c6cd5d01cf68c99c0cfd66b165dce3fd9d9fce97cd4973117cab68355d/pyqwest-0.10.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0879a3f0b37876372aa328b9fee956165db174c1ab72464146fe515f49399ddb", size = 5565620, upload-time = "2026-08-21T06:09:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a6/20a910d2f096908cf25ec47fa84b35b1bbb1af63dc9713f53f38ed97802e/pyqwest-0.10.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:850b8de6ade09a60bdb2f969871a177c2c304b594b2034ba3f5962c7bea75551", size = 5809262, upload-time = "2026-08-21T06:09:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f7/f92fe4004d93f08c5118e750feefdbc72a73438b7fd7083300728e999911/pyqwest-0.10.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:399802647ea646c6ac9b5460e541b7c209b7a13563c667b2690ded2060185f2e", size = 5985550, upload-time = "2026-08-21T06:09:17.579Z" }, + { url = "https://files.pythonhosted.org/packages/47/3e/2c896e54dbe3f1ba6e3bd10e9d412f422a2e33827ff591d59f67b13e0fa5/pyqwest-0.10.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c26f3de1feb5d066d7a66802a47407a93ba043696064ad80beda4a0a4bf10056", size = 4873529, upload-time = "2026-08-21T06:09:19.161Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-dotenv" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[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, upload-time = "2024-03-01T18:36:20.211Z" } +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, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.11.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" }, + { url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" }, + { url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" }, + { url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" }, + { url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" }, + { url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" }, + { url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" }, +] + +[[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, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "wcmatch" +version = "11.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/43/30e407989e313677dbb9d5f045f966549a7254834571e342eaa4b55cc67b/wcmatch-11.0.1.tar.gz", hash = "sha256:1ea2b4fa678b8ca268253798d5963935df39132d47c3e241c0a0732224005e7d", size = 144662, upload-time = "2026-08-14T15:20:40.477Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/77/7a02b0f05b3ffcdbef9719ce3ee0b508d6a29b58e95299f1580055671db3/wcmatch-11.0.1-py3-none-any.whl", hash = "sha256:fd149ecddb9f0a88ea780017d6dde17c994e494e7f7303d4e3c9d6251f978f4b", size = 43449, upload-time = "2026-08-14T15:20:39.379Z" }, +]