From 3ed767da23047e8f41279157fc36a9e3113e23ce Mon Sep 17 00:00:00 2001 From: "Clelia (Astra) Bertelli" Date: Sun, 29 Jun 2025 12:01:17 +0200 Subject: [PATCH] Adding observability dashboard --- .env.example | 3 + .github/workflows/release.yaml | 21 ++ README.md | 6 + compose.yaml | 31 +++ pyproject.toml | 8 +- src/notebooklm_clone/Home.py | 32 ++- src/notebooklm_clone/instrumentation.py | 167 +++++++++++++++ .../pages/2_Observability_Dashboard.py | 169 +++++++++++++++ tests/test_sql_engine.py | 84 ++++++++ uv.lock | 198 +++++++++++++++++- 10 files changed, 707 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/release.yaml create mode 100644 compose.yaml create mode 100644 src/notebooklm_clone/instrumentation.py create mode 100644 src/notebooklm_clone/pages/2_Observability_Dashboard.py create mode 100644 tests/test_sql_engine.py diff --git a/.env.example b/.env.example index 518b6b9..34756db 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ OPENAI_API_KEY="sk-***" LLAMACLOUD_API_KEY="llx-***" ELEVENLABS_API_KEY="sk_***" +pgql_db="postgres" +pgql_user="localhost" +pgql_psw="admin" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..21b5a27 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,21 @@ +name: GitHub Release + +on: + push: + tags: + - "v[0-9].[0-9]+.[0-9]+*" + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Create GitHub Release + uses: ncipollo/release-action@v1 + with: + generateReleaseNotes: true diff --git a/README.md b/README.md index a91a882..f476c0d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,12 @@ uv run tools/create_llama_cloud_index.py And you're ready to set up the app! +Launch Postgres and Jaeger: + +```bash +docker compose up -d +``` + Run the **MCP** server: ```bash diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..37807e9 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,31 @@ +name: instrumentation + +services: + jaeger: + image: jaegertracing/all-in-one:latest + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 9411:9411 + environment: + - COLLECTOR_ZIPKIN_HOST_PORT=:9411 + + postgres: + image: postgres + ports: + - 5432:5432 + environment: + POSTGRES_DB: $pgql_db + POSTGRES_USER: $pgql_user + POSTGRES_PASSWORD: $pgql_psw + volumes: + - pgdata:/var/lib/postgresql/data + + adminer: + image: adminer + ports: + - "8080:8080" + +volumes: + pgdata: diff --git a/pyproject.toml b/pyproject.toml index 8696d48..3aa05e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "notebooklm-clone" -version = "0.1.0" -description = "Add your description here" +version = "0.2.0" +description = "An OSS and LlamaCloud-backed alternative to NotebookLM" readme = "README.md" requires-python = ">=3.13" dependencies = [ @@ -15,10 +15,14 @@ dependencies = [ "llama-index-embeddings-openai>=0.3.1", "llama-index-indices-managed-llama-cloud>=0.6.11", "llama-index-llms-openai>=0.4.7", + "llama-index-observability-otel>=0.1.1", "llama-index-tools-mcp>=0.2.5", "llama-index-workflows>=1.0.1", "mypy>=1.16.1", + "opentelemetry-exporter-otlp-proto-http>=1.34.1", + "plotly>=6.2.0", "pre-commit>=4.2.0", + "psycopg2-binary>=2.9.10", "pydub>=0.25.1", "pytest>=8.4.1", "pytest-asyncio>=1.0.0", diff --git a/src/notebooklm_clone/Home.py b/src/notebooklm_clone/Home.py index 39f1e74..bcd51c2 100644 --- a/src/notebooklm_clone/Home.py +++ b/src/notebooklm_clone/Home.py @@ -3,12 +3,36 @@ import io import os import asyncio import tempfile as temp +from dotenv import load_dotenv +import time import streamlit.components.v1 as components from pathlib import Path from audio import PODCAST_GEN from typing import Tuple from workflow import NotebookLMWorkflow, FileInputEvent, NotebookOutputEvent +from instrumentation import OtelTracesSqlEngine +from llama_index.observability.otel import LlamaIndexOpenTelemetry +from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, +) + +load_dotenv() + +# define a custom span exporter +span_exporter = OTLPSpanExporter("http://0.0.0.0:4318/v1/traces") + +# initialize the instrumentation object +instrumentor = LlamaIndexOpenTelemetry( + service_name_or_resource="agent.traces", + span_exporter=span_exporter, + debug=True, +) +sql_engine = OtelTracesSqlEngine( + engine_url=f"postgresql+psycopg2://{os.getenv('pgql_user')}:{os.getenv('pgql_psw')}@localhost:5432/{os.getenv('pgql_db')}", + table_name="agent_traces", + service_name="agent.traces", +) WF = NotebookLMWorkflow(timeout=600) @@ -24,6 +48,7 @@ async def run_workflow(file: io.BytesIO) -> Tuple[str, str, str, str, str]: content = file.getvalue() with open(fl.name, "wb") as f: f.write(content) + st_time = int(time.time() * 1000000) ev = FileInputEvent(file=fl.name) result: NotebookOutputEvent = await WF.run(start_event=ev) q_and_a = "" @@ -34,7 +59,9 @@ async def run_workflow(file: io.BytesIO) -> Tuple[str, str, str, str, str]: mind_map = result.mind_map if Path(mind_map).is_file(): mind_map = read_html_file(mind_map) - os.remove(mind_map) + os.remove(result.mind_map) + end_time = int(time.time() * 1000000) + sql_engine.to_sql_database(start_time=st_time, end_time=end_time) return result.md_content, result.summary, q_and_a, bullet_points, mind_map @@ -138,3 +165,6 @@ if file_input is not None: else: st.info("Please upload a PDF file to get started.") + +if __name__ == "__main__": + instrumentor.start_registering() diff --git a/src/notebooklm_clone/instrumentation.py b/src/notebooklm_clone/instrumentation.py new file mode 100644 index 0000000..cd97961 --- /dev/null +++ b/src/notebooklm_clone/instrumentation.py @@ -0,0 +1,167 @@ +import requests +import time +import csv +import pandas as pd +import tempfile as temp +import os + +from sqlalchemy import Engine, create_engine, Connection, Result +from typing import Optional, Dict, Any, List, Literal, Union + + +class OtelTracesSqlEngine: + def __init__( + self, + engine: Optional[Engine] = None, + engine_url: Optional[Dict[str, Any]] = None, + table_name: Optional[str] = None, + service_name: Optional[str] = None, + ): + self.service_name: str = service_name or "service" + self.table_name: str = table_name or "otel_traces" + self._connection: Optional[Connection] = None + if engine: + self._engine: Engine = engine + elif engine_url: + self._engine = create_engine(url=engine_url) + else: + raise ValueError("One of engine or engine_setup_kwargs must be set") + + def _connect(self) -> None: + self._connection = self._engine.connect() + + def _export( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, + ) -> Dict[str, Any]: + url = "http://localhost:16686/api/traces" + params = { + "service": self.service_name, + "start": start_time + or int(time.time() * 1000000) - (24 * 60 * 60 * 1000000), + "end": end_time or int(time.time() * 1000000), + "limit": limit or 1000, + } + response = requests.get(url, params=params) + print(response.json()) + return response.json() + + def _to_pandas(self, data: Dict[str, Any]) -> pd.DataFrame: + rows: List[Dict[str, Any]] = [] + # Loop over each trace + for trace in data.get("data", []): + trace_id = trace.get("traceID") + service_map = { + pid: proc.get("serviceName") + for pid, proc in trace.get("processes", {}).items() + } + + for span in trace.get("spans", []): + span_id = span.get("spanID") + operation = span.get("operationName") + start = span.get("startTime") + duration = span.get("duration") + process_id = span.get("processID") + service = service_map.get(process_id, "") + status = next( + ( + tag.get("value") + for tag in span.get("tags", []) + if tag.get("key") == "otel.status_code" + ), + "", + ) + parent_span_id = None + if span.get("references"): + parent_span_id = span["references"][0].get("spanID") + + rows.append( + { + "trace_id": trace_id, + "span_id": span_id, + "parent_span_id": parent_span_id, + "operation_name": operation, + "start_time": start, + "duration": duration, + "status_code": status, + "service_name": service, + } + ) + + # Define the CSV header + fieldnames = [ + "trace_id", + "span_id", + "parent_span_id", + "operation_name", + "start_time", + "duration", + "status_code", + "service_name", + ] + + fl = temp.NamedTemporaryFile(suffix=".csv", delete=False, delete_on_close=False) + # Write to CSV + with open(fl.name, "w", newline="") as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + df = pd.read_csv(fl) + os.remove(fl.name) + return df + + def _to_sql( + self, + dataframe: pd.DataFrame, + if_exists_policy: Optional[Literal["fail", "replace", "append"]] = None, + ) -> None: + if not self._connection: + self._connect() + dataframe.to_sql( + name=self.table_name, + con=self._connection, + if_exists=if_exists_policy or "append", + ) + + def to_sql_database( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, + if_exists_policy: Optional[Literal["fail", "replace", "append"]] = None, + ) -> None: + data = self._export(start_time=start_time, end_time=end_time, limit=limit) + df = self._to_pandas(data=data) + self._to_sql(dataframe=df, if_exists_policy=if_exists_policy) + + def execute( + self, + statement: str, + parameters: Optional[Union[Dict[str, Any], List[Dict[str, Any]]]] = None, + execution_options: Optional[Dict[str, Any]] = None, + return_pandas: bool = False, + ) -> Union[Result, pd.DataFrame]: + if not self._connection: + self._connect() + if not return_pandas: + return self._connection.execute( + statement=statement, + parameters=parameters, + execution_options=execution_options, + ) + return pd.read_sql(sql=statement, con=self._connection) + + def to_pandas( + self, + ) -> pd.DataFrame: + if not self._connection: + self._connect() + return pd.read_sql_table(table_name=self.table_name, con=self._connection) + + def disconnect(self) -> None: + if not self._connection: + raise ValueError("Engine was never connected!") + self._engine.dispose(close=True) diff --git a/src/notebooklm_clone/pages/2_Observability_Dashboard.py b/src/notebooklm_clone/pages/2_Observability_Dashboard.py new file mode 100644 index 0000000..cd60aad --- /dev/null +++ b/src/notebooklm_clone/pages/2_Observability_Dashboard.py @@ -0,0 +1,169 @@ +import sys +import os +import pandas as pd +import streamlit as st +import plotly.express as px +import plotly.graph_objects as go + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from dotenv import load_dotenv +from instrumentation import OtelTracesSqlEngine +from sqlalchemy import text + +load_dotenv() + +sql_engine = OtelTracesSqlEngine( + engine_url=f"postgresql+psycopg2://{os.getenv('pgql_user')}:{os.getenv('pgql_psw')}@localhost:5432/{os.getenv('pgql_db')}", + table_name="agent_traces", + service_name="agent.traces", +) + + +def display_sql() -> pd.DataFrame: + query = """CREATE TABLE IF NOT EXISTS agent_traces ( + trace_id TEXT NOT NULL, + span_id TEXT NOT NULL, + parent_span_id TEXT NULL, + operation_name TEXT NOT NULL, + start_time BIGINT NOT NULL, + duration INTEGER NOT NULL, + status_code TEXT NOT NULL, + service_name TEXT NOT NULL + );""" + sql_engine.execute(text(query)) + return sql_engine.to_pandas() + + +def filter_traces(sql_query: str): + df = sql_engine.execute(text(sql_query), return_pandas=True) + return df + + +def create_latency_chart(df: pd.DataFrame): + """Create a line chart showing latency (duration) over time""" + if df.empty: + st.warning("No data available for latency chart") + return + + # Convert start_time from nanoseconds to datetime + df_chart = df.copy() + progressive_count = list(range(0, len(df_chart["start_time"]))) + df_chart["progressive_count"] = progressive_count + + fig = px.line( + df_chart, + x="progressive_count", + y="duration", + title="Latency Overview", + labels={"duration": "Duration (ns)", "timestamp": "Time"}, + hover_data=["operation_name", "status_code"], + ) + + fig.update_layout( + xaxis_title="Time", yaxis_title="Duration (nanoseconds)", hovermode="x unified" + ) + + st.plotly_chart(fig) + + +def create_status_pie_chart(df: pd.DataFrame): + """Create a pie chart showing status code distribution""" + if df.empty: + st.warning("No data available for status code chart") + return + + # Count status codes + status_counts = df["status_code"].value_counts() + + # Map common status codes to more readable labels + status_labels = { + "OK": "OK", + "ERROR": "ERROR", + "UNSET": "UNSET", + "200": "OK (200)", + "500": "ERROR (500)", + "404": "ERROR (404)", + } + + # Create labels and values for the pie chart + labels = [status_labels.get(status, status) for status in status_counts.index] + values = status_counts.values + + # Define colors + colors = [] + for status in status_counts.index: + if status in ["OK", "200"]: + colors.append("#28a745") # Green for OK + elif status in ["ERROR", "500", "404"]: + colors.append("#dc3545") # Red for ERROR + else: + colors.append("#6c757d") # Gray for others + + fig = go.Figure( + data=[go.Pie(labels=labels, values=values, hole=0.3, marker_colors=colors)] + ) + + fig.update_layout( + title="Status Code Distribution", + annotations=[dict(text="Status", x=0.5, y=0.5, font_size=20, showarrow=False)], + ) + + st.plotly_chart(fig) + + +# Streamlit UI +st.set_page_config(page_title="NotebookLlaMa - Observability Dashboard", page_icon="🔍") + +st.sidebar.header("Observability Dashboard🔍") +st.sidebar.info("To switch to the other pages, select them from above!🔺") +st.markdown("---") +st.markdown("## NotebookLlaMa - Observability Dashboard🔍") + +# Get the data +df_data = display_sql() + +# Charts section +st.markdown("## 📊 Analytics Overview") + +if not df_data.empty: + col1, col2 = st.columns(2) + + with col1: + create_latency_chart(df_data) + + with col2: + create_status_pie_chart(df_data) +else: + st.info("No trace data available yet. Charts will appear once data is collected.") + +st.markdown("---") + +# SQL Query section +st.markdown("### SQL Query") +sql_query = st.text_input(label="") + +st.markdown("## Traces Table") +dataframe = st.dataframe(data=df_data) + +if st.button("Run SQL query", type="primary"): + if sql_query.strip(): + try: + filtered_df = filter_traces(sql_query=sql_query) + st.markdown("### Query Results") + dataframe = st.dataframe(data=filtered_df) + + # Update charts with filtered data + if not filtered_df.empty: + st.markdown("### Updated Charts") + col1, col2 = st.columns(2) + + with col1: + create_latency_chart(filtered_df) + + with col2: + create_status_pie_chart(filtered_df) + except Exception as e: + st.error(f"Error executing query: {str(e)}") + else: + st.warning("Please enter a SQL query") diff --git a/tests/test_sql_engine.py b/tests/test_sql_engine.py new file mode 100644 index 0000000..8a62502 --- /dev/null +++ b/tests/test_sql_engine.py @@ -0,0 +1,84 @@ +import socket +import pytest +import pandas as pd +import os +from dotenv import load_dotenv + +from src.notebooklm_clone.instrumentation import OtelTracesSqlEngine +from sqlalchemy import text + +ENV = load_dotenv() + + +def is_port_open(host: str, port: int, timeout: float = 2.0) -> bool: + """Check if a TCP port is open on a given host.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.settimeout(timeout) + result = sock.connect_ex((host, port)) + return result == 0 + + +@pytest.fixture() +def otel_data() -> pd.DataFrame: + return pd.DataFrame( + { + "trace_id": ["abc123", "abc123", "def456"], + "span_id": ["span1", "span2", "span3"], + "parent_span_id": [None, "span1", "span2"], + "operation_name": [ + "ServiceA.handle_request", + "ServiceA.query_db", + "ServiceB.send_email", + ], + "start_time": [1750618321000000, 1750618321000100, 1750618321000200], + "duration": [150, 300, 500], + "status_code": ["OK", "OK", "ERROR"], + "service_name": ["service-a", "service-a", "service-b"], + } + ) + + +@pytest.mark.skipif( + condition=not is_port_open(host="localhost", port=5432) and not ENV, + reason="Either Postgres is currently unavailable or you did not set any env variables in a .env file", +) +def test_engine(otel_data: pd.DataFrame) -> None: + engine_url = f"postgresql+psycopg2://{os.getenv('pgql_user')}:{os.getenv('pgql_psw')}@localhost:5432/{os.getenv('pgql_db')}" + sql_engine = OtelTracesSqlEngine(engine_url=engine_url, table_name="test") + res = sql_engine.execute(text("DROP TABLE IF EXISTS test;")) + res.close() + sql_engine._to_sql(dataframe=otel_data) + res1 = sql_engine.execute( + text( + "SELECT span_id, operation_name, duration FROM test WHERE status_code = 'ERROR'" + ) + ) + res2 = sql_engine.execute( + text( + "SELECT service_name, AVG(duration) AS avg_duration FROM test GROUP BY service_name;" + ) + ) + res1_data = res1.fetchall() + res2_data = res2.fetchall() + + # Compare just the values + assert len(res1_data) == 1 + assert res1_data[0].span_id == "span3" + assert res1_data[0].operation_name == "ServiceB.send_email" + assert res1_data[0].duration == 500 + + assert len(res2_data) == 2 + # Sort by service_name for consistent comparison + res2_sorted = sorted(res2_data, key=lambda x: x.service_name) + assert res2_sorted[0].service_name == "service-a" + assert res2_sorted[0].avg_duration == 225.0 + assert res2_sorted[1].service_name == "service-b" + assert res2_sorted[1].avg_duration == 500.0 + assert isinstance(sql_engine.to_pandas(), pd.DataFrame) + res3 = sql_engine.execute( + text( + "SELECT service_name, AVG(duration) AS avg_duration FROM test GROUP BY service_name;" + ), + return_pandas=True, + ) + assert isinstance(res3, pd.DataFrame) diff --git a/uv.lock b/uv.lock index 1e8337e..d9245c7 100644 --- a/uv.lock +++ b/uv.lock @@ -544,6 +544,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599, upload-time = "2025-01-02T07:32:40.731Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + [[package]] name = "greenlet" version = "3.2.3" @@ -644,6 +656,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -912,6 +936,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/e9/391926dad180ced6bb37a62edddb8483fbecde411239bd5e726841bb77b4/llama_index_llms_openai-0.4.7-py3-none-any.whl", hash = "sha256:3b8d9d3c1bcadc2cff09724de70f074f43eafd5b7048a91247c9a41b7cd6216d", size = 25365, upload-time = "2025-06-16T03:38:45.72Z" }, ] +[[package]] +name = "llama-index-observability-otel" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llama-index-core" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "termcolor" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/f2/22c28678fad040c579b9280769ffb79dc5c60d061b8a4b2e0d764787ceb0/llama_index_observability_otel-0.1.1.tar.gz", hash = "sha256:a3f25d0105225d609a198506ecb27c24e420ad28871cd8f2227ee27e55765eda", size = 6391, upload-time = "2025-05-29T04:24:11.04Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/3f/14796f0e0f975378e913240041bb3658216432eceb34d9b02eb39c4dd604/llama_index_observability_otel-0.1.1-py3-none-any.whl", hash = "sha256:d70ccd207c1ad6f31e6697cd8bca093e170c8589042beef17741fd56cef9f115", size = 6311, upload-time = "2025-05-29T04:24:09.826Z" }, +] + [[package]] name = "llama-index-tools-mcp" version = "0.2.5" @@ -1155,7 +1195,7 @@ wheels = [ [[package]] name = "notebooklm-clone" -version = "0.1.0" +version = "0.2.0" source = { virtual = "." } dependencies = [ { name = "audioop-lts" }, @@ -1168,10 +1208,14 @@ dependencies = [ { name = "llama-index-embeddings-openai" }, { name = "llama-index-indices-managed-llama-cloud" }, { name = "llama-index-llms-openai" }, + { name = "llama-index-observability-otel" }, { name = "llama-index-tools-mcp" }, { name = "llama-index-workflows" }, { name = "mypy" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "plotly" }, { name = "pre-commit" }, + { name = "psycopg2-binary" }, { name = "pydub" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1192,10 +1236,14 @@ requires-dist = [ { name = "llama-index-embeddings-openai", specifier = ">=0.3.1" }, { name = "llama-index-indices-managed-llama-cloud", specifier = ">=0.6.11" }, { name = "llama-index-llms-openai", specifier = ">=0.4.7" }, + { name = "llama-index-observability-otel", specifier = ">=0.1.1" }, { name = "llama-index-tools-mcp", specifier = ">=0.2.5" }, { name = "llama-index-workflows", specifier = ">=1.0.1" }, { name = "mypy", specifier = ">=1.16.1" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.34.1" }, + { name = "plotly", specifier = ">=6.2.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "pydub", specifier = ">=0.25.1" }, { name = "pytest", specifier = ">=8.4.1" }, { name = "pytest-asyncio", specifier = ">=1.0.0" }, @@ -1265,6 +1313,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/94a8cb759e4e409022229418294e098ca7feca00eb3c467bb20cbd329bda/opentelemetry_api-1.34.1.tar.gz", hash = "sha256:64f0bd06d42824843731d05beea88d4d4b6ae59f9fe347ff7dfa2cc14233bbb3", size = 64987, upload-time = "2025-06-10T08:55:19.818Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/3a/2ba85557e8dc024c0842ad22c570418dc02c36cbd1ab4b832a93edf071b8/opentelemetry_api-1.34.1-py3-none-any.whl", hash = "sha256:b7df4cb0830d5a6c29ad0c0691dbae874d8daefa934b8b1d642de48323d32a8c", size = 65767, upload-time = "2025-06-10T08:54:56.717Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f0/ff235936ee40db93360233b62da932d4fd9e8d103cd090c6bcb9afaf5f01/opentelemetry_exporter_otlp_proto_common-1.34.1.tar.gz", hash = "sha256:b59a20a927facd5eac06edaf87a07e49f9e4a13db487b7d8a52b37cb87710f8b", size = 20817, upload-time = "2025-06-10T08:55:22.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/e8/8b292a11cc8d8d87ec0c4089ae21b6a58af49ca2e51fa916435bc922fdc7/opentelemetry_exporter_otlp_proto_common-1.34.1-py3-none-any.whl", hash = "sha256:8e2019284bf24d3deebbb6c59c71e6eef3307cd88eff8c633e061abba33f7e87", size = 18834, upload-time = "2025-06-10T08:55:00.806Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/8f/954bc725961cbe425a749d55c0ba1df46832a5999eae764d1a7349ac1c29/opentelemetry_exporter_otlp_proto_http-1.34.1.tar.gz", hash = "sha256:aaac36fdce46a8191e604dcf632e1f9380c7d5b356b27b3e0edb5610d9be28ad", size = 15351, upload-time = "2025-06-10T08:55:24.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/54/b05251c04e30c1ac70cf4a7c5653c085dfcf2c8b98af71661d6a252adc39/opentelemetry_exporter_otlp_proto_http-1.34.1-py3-none-any.whl", hash = "sha256:5251f00ca85872ce50d871f6d3cc89fe203b94c3c14c964bbdc3883366c705d8", size = 17744, upload-time = "2025-06-10T08:55:03.802Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/b3/c3158dd012463bb7c0eb7304a85a6f63baeeb5b4c93a53845cf89f848c7e/opentelemetry_proto-1.34.1.tar.gz", hash = "sha256:16286214e405c211fc774187f3e4bbb1351290b8dfb88e8948af209ce85b719e", size = 34344, upload-time = "2025-06-10T08:55:32.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/ab/4591bfa54e946350ce8b3f28e5c658fe9785e7cd11e9c11b1671a867822b/opentelemetry_proto-1.34.1-py3-none-any.whl", hash = "sha256:eb4bb5ac27f2562df2d6857fc557b3a481b5e298bc04f94cc68041f00cebcbd2", size = 55692, upload-time = "2025-06-10T08:55:14.904Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.34.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/41/fe20f9036433da8e0fcef568984da4c1d1c771fa072ecd1a4d98779dccdd/opentelemetry_sdk-1.34.1.tar.gz", hash = "sha256:8091db0d763fcd6098d4781bbc80ff0971f94e260739aa6afe6fd379cdf3aa4d", size = 159441, upload-time = "2025-06-10T08:55:33.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/1b/def4fe6aa73f483cabf4c748f4c25070d5f7604dcc8b52e962983491b29e/opentelemetry_sdk-1.34.1-py3-none-any.whl", hash = "sha256:308effad4059562f1d92163c61c8141df649da24ce361827812c40abb2a1e96e", size = 118477, upload-time = "2025-06-10T08:55:16.02Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.55b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/f0/f33458486da911f47c4aa6db9bda308bb80f3236c111bf848bd870c16b16/opentelemetry_semantic_conventions-0.55b1.tar.gz", hash = "sha256:ef95b1f009159c28d7a7849f5cbc71c4c34c845bb514d66adfdf1b3fff3598b3", size = 119829, upload-time = "2025-06-10T08:55:33.881Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/89/267b0af1b1d0ba828f0e60642b6a5116ac1fd917cde7fc02821627029bd1/opentelemetry_semantic_conventions-0.55b1-py3-none-any.whl", hash = "sha256:5da81dfdf7d52e3d37f8fe88d5e771e191de924cfff5f550ab0b8f7b2409baed", size = 196223, upload-time = "2025-06-10T08:55:17.638Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -1370,6 +1500,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/39/979e8e21520d4e47a0bbe349e2713c0aac6f3d853d0e5b34d76206c439aa/platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4", size = 18567, upload-time = "2025-05-07T22:47:40.376Z" }, ] +[[package]] +name = "plotly" +version = "6.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/5c/0efc297df362b88b74957a230af61cd6929f531f72f48063e8408702ffba/plotly-6.2.0.tar.gz", hash = "sha256:9dfa23c328000f16c928beb68927444c1ab9eae837d1fe648dbcda5360c7953d", size = 6801941, upload-time = "2025-06-26T16:20:45.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/20/f2b7ac96a91cc5f70d81320adad24cc41bf52013508d649b1481db225780/plotly-6.2.0-py3-none-any.whl", hash = "sha256:32c444d4c940887219cb80738317040363deefdfee4f354498cc0b6dab8978bd", size = 9635469, upload-time = "2025-06-26T16:20:40.76Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1450,16 +1593,35 @@ wheels = [ [[package]] name = "protobuf" -version = "6.31.1" +version = "5.29.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797, upload-time = "2025-05-28T19:25:54.947Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603, upload-time = "2025-05-28T19:25:41.198Z" }, - { url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283, upload-time = "2025-05-28T19:25:44.275Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604, upload-time = "2025-05-28T19:25:45.702Z" }, - { url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115, upload-time = "2025-05-28T19:25:47.128Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070, upload-time = "2025-05-28T19:25:50.036Z" }, - { url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724, upload-time = "2025-05-28T19:25:53.926Z" }, + { url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/81/7f/73cefb093e1a2a7c3ffd839e6f9fcafb7a427d300c7f8aef9c64405d8ac6/protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc", size = 434818, upload-time = "2025-05-28T23:51:44.297Z" }, + { url = "https://files.pythonhosted.org/packages/dd/73/10e1661c21f139f2c6ad9b23040ff36fee624310dc28fba20d33fdae124c/protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671", size = 418091, upload-time = "2025-05-28T23:51:45.907Z" }, + { url = "https://files.pythonhosted.org/packages/6c/04/98f6f8cf5b07ab1294c13f34b4e69b3722bb609c5b701d6c169828f9f8aa/protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015", size = 319824, upload-time = "2025-05-28T23:51:47.545Z" }, + { url = "https://files.pythonhosted.org/packages/85/e4/07c80521879c2d15f321465ac24c70efe2381378c00bf5e56a0f4fbac8cd/protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61", size = 319942, upload-time = "2025-05-28T23:51:49.11Z" }, + { url = "https://files.pythonhosted.org/packages/7e/cc/7e77861000a0691aeea8f4566e5d3aa716f2b1dece4a24439437e41d3d25/protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5", size = 172823, upload-time = "2025-05-28T23:51:58.157Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, ] [[package]] @@ -1947,6 +2109,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] +[[package]] +name = "termcolor" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/6c/3d75c196ac07ac8749600b60b03f4f6094d54e132c4d94ebac6ee0e0add0/termcolor-3.1.0.tar.gz", hash = "sha256:6a6dd7fbee581909eeec6a756cff1d7f7c376063b14e4a298dc4980309e55970", size = 14324, upload-time = "2025-04-30T11:37:53.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684, upload-time = "2025-04-30T11:37:52.382Z" }, +] + [[package]] name = "tiktoken" version = "0.9.0" @@ -2233,3 +2404,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/c3/b2e9f38bc3e11191981d57ea08cab2166e74ea770024a646617c9cddd9f6/yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f", size = 93003, upload-time = "2025-06-10T00:45:27.752Z" }, { url = "https://files.pythonhosted.org/packages/b4/2d/2345fce04cfd4bee161bf1e7d9cdc702e3e16109021035dbb24db654a622/yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77", size = 46542, upload-time = "2025-06-10T00:46:07.521Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]