deploy
This commit is contained in:
parent
8c1258aa4f
commit
316f6f7388
@ -1 +1 @@
|
|||||||
from .xl import *
|
from .asr import *
|
||||||
|
|||||||
125
asr/README.md
Normal file
125
asr/README.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# Triton ASR Client
|
||||||
|
|
||||||
|
Async Python client for sending audio to a Triton ASR service using **SSE** or **WebSocket** streaming.
|
||||||
|
It yields real-time transcription results as JSON-like events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- 🚀 Async SSE & WebSocket streaming modes
|
||||||
|
- 🎧 Streams audio bytes directly
|
||||||
|
- 🧩 Yields parsed dict events (partial/final text, status, errors)
|
||||||
|
- 🔧 Simple integration with Triton Inference Server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Python 3.10+
|
||||||
|
- `tritonclient[grpc]`, `tritonclient[http]`, `websockets`
|
||||||
|
|
||||||
|
Install:
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
Set your Triton endpoint (default: `localhost:8001`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export TRITON_URL="your.triton.server:8001"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example — SSE Mode
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from .service import TritonGrpcClient
|
||||||
|
import json, os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TRITON_URL = os.getenv("TRITON_URL", "localhost:8001")
|
||||||
|
client = TritonGrpcClient(triton_url=TRITON_URL)
|
||||||
|
|
||||||
|
async def asr_sse_mode(client, audio_path: Path):
|
||||||
|
print("=== SSE MODE ===")
|
||||||
|
raw = audio_path.read_bytes()
|
||||||
|
try:
|
||||||
|
async for s in client.event_stream_from_bytes(raw=raw, filename=audio_path.name):
|
||||||
|
print("[SSE]", json.dumps(s, ensure_ascii=False))
|
||||||
|
except Exception as exc:
|
||||||
|
print("SSE error:", exc)
|
||||||
|
|
||||||
|
asyncio.run(asr_sse_mode(client, Path("/test_audio_file.mp3")))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example — WebSocket Mode
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
from .service import TritonGrpcClient
|
||||||
|
import json, os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TRITON_URL = os.getenv("TRITON_URL", "localhost:8001")
|
||||||
|
client = TritonGrpcClient(triton_url=TRITON_URL)
|
||||||
|
|
||||||
|
async def asr_ws_mode(client, audio_path: Path):
|
||||||
|
print("=== WS MODE ===")
|
||||||
|
raw = audio_path.read_bytes()
|
||||||
|
try:
|
||||||
|
async for s in client.event_stream_via_ws(raw=raw, filename=audio_path.name):
|
||||||
|
print("[WS]", json.dumps(s, ensure_ascii=False))
|
||||||
|
except Exception as exc:
|
||||||
|
print("WS error:", exc)
|
||||||
|
|
||||||
|
asyncio.run(asr_ws_mode(client, Path("/test_audio_file.mp3")))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Output
|
||||||
|
Below is a real example of SSE output while streaming an MP3 file:
|
||||||
|
|
||||||
|
```
|
||||||
|
[BG] Entering stream_transcript loop
|
||||||
|
[SSE] OUT: "data: {"time": 1760512211.4691734, "text": "سلام وقت", "is_final": false}\n\n"
|
||||||
|
[SSE] OUT: "data: {"time": 1760512211.55668, "text": "بهخیر", "is_final": false}\n\n"
|
||||||
|
...
|
||||||
|
[SSE] OUT: "data: {"time": 1760512226.2526345, "text": "سلام وقتبهخیر امروز درباره طراحی جدید صحبت میکنیم", "is_final": true}\n\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
Each event contains:
|
||||||
|
- `time`: event timestamp
|
||||||
|
- `text`: recognized speech fragment
|
||||||
|
- `is_final`: indicates final transcript segment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Overview
|
||||||
|
| Method | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `TritonGrpcClient(triton_url)` | Create a client connected to Triton |
|
||||||
|
| `event_stream_from_bytes(raw, filename)` | SSE-based audio streaming |
|
||||||
|
| `event_stream_via_ws(raw, filename)` | WebSocket-based audio streaming |
|
||||||
|
|
||||||
|
Each stream yields dict events like:
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"time": 1760512211.47,
|
||||||
|
"text": "hello world",
|
||||||
|
"is_final": True
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
- **Connection refused** → Check `TRITON_URL` and Triton server status
|
||||||
|
- **Bad event data** → Verify model/gateway returns valid JSON events
|
||||||
|
- **WS handshake failed** → Ensure the server supports WebSocket
|
||||||
|
|
||||||
|
---
|
||||||
1
asr/__init__.py
Normal file
1
asr/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .service import TritonGrpcClient
|
||||||
80
asr/decode_stream.py
Normal file
80
asr/decode_stream.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# decode_stream.py
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import av
|
||||||
|
from av.audio.resampler import AudioResampler
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
class HLSReader:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
sample_rate: int = 48_000,
|
||||||
|
channels: int = 1,
|
||||||
|
chunk_ms: int = 20,
|
||||||
|
):
|
||||||
|
self.url = url
|
||||||
|
self.sample_rate = sample_rate
|
||||||
|
self.channels = channels
|
||||||
|
self.chunk_ms = chunk_ms
|
||||||
|
|
||||||
|
self._stop_evt = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._queue: "asyncio.Queue[bytes] | None" = None
|
||||||
|
|
||||||
|
# derived
|
||||||
|
self._bytes_per_sample = 2 # s16
|
||||||
|
self._samples_per_chunk = int(self.sample_rate * (self.chunk_ms / 1000.0))
|
||||||
|
self._chunk_bytes = self._samples_per_chunk * self.channels * self._bytes_per_sample
|
||||||
|
|
||||||
|
def start(self, loop: asyncio.AbstractEventLoop, out_queue: "asyncio.Queue[bytes]") -> None:
|
||||||
|
"""Begin reading in a daemon thread, pushing chunks onto out_queue (on 'loop')."""
|
||||||
|
self._loop = loop
|
||||||
|
self._queue = out_queue
|
||||||
|
self._thread = threading.Thread(target=self._run, name=f"hls-{id(self):x}", daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_evt.set()
|
||||||
|
|
||||||
|
# ---------- internal ----------
|
||||||
|
def _run(self) -> None:
|
||||||
|
assert self._loop and self._queue
|
||||||
|
while not self._stop_evt.is_set():
|
||||||
|
try:
|
||||||
|
container = av.open(self.url, mode="r")
|
||||||
|
audio_stream = next(s for s in container.streams if s.type == "audio")
|
||||||
|
|
||||||
|
resampler = AudioResampler(
|
||||||
|
format="s16",
|
||||||
|
layout="mono" if self.channels == 1 else "stereo",
|
||||||
|
rate=self.sample_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = bytearray()
|
||||||
|
for packet in container.demux(audio_stream):
|
||||||
|
if self._stop_evt.is_set():
|
||||||
|
break
|
||||||
|
for frame in packet.decode():
|
||||||
|
for out in resampler.resample(frame):
|
||||||
|
buf.extend(out.planes[0].to_bytes())
|
||||||
|
while len(buf) >= self._chunk_bytes:
|
||||||
|
chunk = bytes(buf[: self._chunk_bytes])
|
||||||
|
del buf[: self._chunk_bytes]
|
||||||
|
# push into asyncio queue on the event loop thread
|
||||||
|
self._loop.call_soon_threadsafe(self._queue.put_nowait, chunk)
|
||||||
|
|
||||||
|
# Some HLS variants EOF → reopen
|
||||||
|
try:
|
||||||
|
container.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not self._stop_evt.is_set():
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
# transient network/playlist issues → backoff & retry
|
||||||
|
time.sleep(1.0)
|
||||||
164
asr/run.py
Normal file
164
asr/run.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
run.py — demo runner for TritonGrpcClient service methods.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python run.py # auto-find an audio file in cwd and run both modes
|
||||||
|
python run.py /path/to/file # run both modes on a specific file
|
||||||
|
python run.py --mode sse # run only SSE-mode
|
||||||
|
python run.py --mode ws # run only WebSocket-mode
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
# Adjust import to where your client class lives
|
||||||
|
# e.g. from triton_client import TritonGrpcClient
|
||||||
|
from .service import TritonGrpcClient
|
||||||
|
|
||||||
|
# Small helper to find an audio file if none provided
|
||||||
|
AUDIO_EXTS = [".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac"]
|
||||||
|
|
||||||
|
|
||||||
|
def find_first_audio_in_cwd() -> Path | None:
|
||||||
|
cwd = Path.cwd()
|
||||||
|
for p in cwd.iterdir():
|
||||||
|
if p.suffix.lower() in AUDIO_EXTS and p.is_file():
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Fake WebSocket for exercising websocket_stream_from_websocket ----------
|
||||||
|
class FakeWebSocket:
|
||||||
|
"""
|
||||||
|
Minimal fake WebSocket implementing the methods used by websocket_stream_from_websocket:
|
||||||
|
- accept()
|
||||||
|
- receive() -> dict with "bytes" or "text"
|
||||||
|
- send_json(obj)
|
||||||
|
- close()
|
||||||
|
It streams the provided bytes in small binary frames, then a JSON text frame {"event":"end"}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes, frame_size: int = 16 * 1024):
|
||||||
|
self._data = data
|
||||||
|
self._frame_size = frame_size
|
||||||
|
self._offset = 0
|
||||||
|
self._sent_end = False
|
||||||
|
self.sent_messages: List[dict] = []
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
async def accept(self):
|
||||||
|
# server expects to optionally call accept; nothing to do
|
||||||
|
print("[FakeWebSocket] accept() called")
|
||||||
|
|
||||||
|
async def receive(self):
|
||||||
|
"""
|
||||||
|
Return one frame at a time:
|
||||||
|
- {"bytes": b"..."} while data remains
|
||||||
|
- then {"text": json.dumps({"event":"end"})}
|
||||||
|
After that, sleep forever (server won't call receive again in your code).
|
||||||
|
"""
|
||||||
|
if self._offset < len(self._data):
|
||||||
|
end = min(len(self._data), self._offset + self._frame_size)
|
||||||
|
chunk = self._data[self._offset : end]
|
||||||
|
self._offset = end
|
||||||
|
# mimic the WebSocket dict shape used in your code
|
||||||
|
return {"bytes": chunk}
|
||||||
|
if not self._sent_end:
|
||||||
|
self._sent_end = True
|
||||||
|
return {"text": json.dumps({"event": "end"})}
|
||||||
|
# Block a bit — server should have stopped receiving after 'end'
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def send_json(self, obj):
|
||||||
|
# server sends results here; capture & print
|
||||||
|
self.sent_messages.append(obj)
|
||||||
|
print("[FakeWebSocket] send_json:", json.dumps(obj, ensure_ascii=False))
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
self.closed = True
|
||||||
|
print("[FakeWebSocket] close() called")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Demo runners ----------
|
||||||
|
async def asr_sse_mode(client: TritonGrpcClient, audio_path: Path):
|
||||||
|
print("\n=== SSE MODE (event_stream_from_bytes) ===")
|
||||||
|
raw = audio_path.read_bytes()
|
||||||
|
# event_stream_from_bytes returns an async generator — iterate it
|
||||||
|
try:
|
||||||
|
async for s in client.event_stream_from_bytes(raw=raw, filename=audio_path.name):
|
||||||
|
# s is already a dict-like object emitted by your SSE generator
|
||||||
|
print("[SSE] OUT:", json.dumps(s, ensure_ascii=False))
|
||||||
|
except Exception as exc:
|
||||||
|
print("[SSE] Exception while streaming SSE:", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def asr_ws_mode(client: TritonGrpcClient, audio_path: Path):
|
||||||
|
print("\n=== WEBSOCKET MODE (websocket_stream_from_websocket) ===")
|
||||||
|
raw = audio_path.read_bytes()
|
||||||
|
fake_ws = FakeWebSocket(raw, frame_size=16 * 1024)
|
||||||
|
|
||||||
|
# Run the server-side websocket handler. It will call fake_ws.receive() and fake_ws.send_json()
|
||||||
|
try:
|
||||||
|
await client.websocket_stream_from_websocket(fake_ws, filename=audio_path.name)
|
||||||
|
except Exception as exc:
|
||||||
|
print("[WS] Exception while running websocket_stream_from_websocket:", exc)
|
||||||
|
finally:
|
||||||
|
print("[WS] Collected messages from server (send_json calls):")
|
||||||
|
for i, m in enumerate(fake_ws.sent_messages, 1):
|
||||||
|
print(f" [{i}] {json.dumps(m, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main_async(audio_path: Path, modes: List[str]):
|
||||||
|
client = TritonGrpcClient() # init; if your client needs args, adjust here
|
||||||
|
|
||||||
|
if "sse" in modes:
|
||||||
|
await asr_sse_mode(client, audio_path)
|
||||||
|
|
||||||
|
if "ws" in modes:
|
||||||
|
await asr_ws_mode(client, audio_path)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("file", nargs="?", help="audio file path (optional). If omitted, searches cwd.")
|
||||||
|
p.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["sse", "ws", "both"],
|
||||||
|
default="both",
|
||||||
|
help="Which method(s) to run against the service",
|
||||||
|
)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
if args.file:
|
||||||
|
audio_path = Path(args.file)
|
||||||
|
if not audio_path.exists():
|
||||||
|
print("Audio file does not exist:", audio_path)
|
||||||
|
sys.exit(2)
|
||||||
|
else:
|
||||||
|
found = find_first_audio_in_cwd()
|
||||||
|
if not found:
|
||||||
|
print("No audio file found in cwd. Place an audio file (wav/mp3/m4a/flac/ogg) here or pass a path.")
|
||||||
|
sys.exit(2)
|
||||||
|
audio_path = found
|
||||||
|
|
||||||
|
modes = ["sse", "ws"] if args.mode == "both" else [args.mode]
|
||||||
|
print(f"Using audio file: {audio_path} — running modes: {modes}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(main_async(audio_path, modes))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("Interrupted.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
610
asr/service.py
Normal file
610
asr/service.py
Normal file
@ -0,0 +1,610 @@
|
|||||||
|
# clients/triton_grpc_client.py
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os, uuid, time, json, asyncio, tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
from typing import AsyncGenerator, List, Tuple, Optional, Callable, Awaitable, TYPE_CHECKING
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from pydub import AudioSegment
|
||||||
|
import tritonclient.grpc.aio as grpcclient
|
||||||
|
from tritonclient.utils import np_to_triton_dtype
|
||||||
|
import websockets
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# if TYPE_CHECKING:
|
||||||
|
# from fastapi import WebSocket
|
||||||
|
# from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
|
# ---- constants (same as your code) ----
|
||||||
|
SAMPLE_RATE_HZ = 16_000
|
||||||
|
INPUT_WAV_TENSOR = "WAV"
|
||||||
|
INPUT_LEN_TENSOR = "WAV_LENS"
|
||||||
|
OUTPUT_TEXT_TENSOR = "TRANSCRIPTS"
|
||||||
|
MODEL_NAME = "transducer"
|
||||||
|
ZERO_PAD_REQUEST_CONTENT = True
|
||||||
|
TRITON_URL = os.getenv("TRITON_URL")
|
||||||
|
|
||||||
|
|
||||||
|
class _WebsocketsAdapter:
|
||||||
|
"""
|
||||||
|
Adapter to make a `websockets` WebSocketServerProtocol behave like
|
||||||
|
a Starlette/FastAPI WebSocket for the shape your code expects:
|
||||||
|
- accept()
|
||||||
|
- receive() -> dict with "bytes" or "text"
|
||||||
|
- send_json(obj)
|
||||||
|
- close()
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, ws):
|
||||||
|
self._ws = ws
|
||||||
|
self._is_server_protocol = True # semantic flag
|
||||||
|
|
||||||
|
async def accept(self):
|
||||||
|
# websockets server does its own accept when created; no-op here
|
||||||
|
return
|
||||||
|
|
||||||
|
async def receive(self):
|
||||||
|
# websockets.recv() returns bytes or str
|
||||||
|
data = await self._ws.recv()
|
||||||
|
if isinstance(data, bytes):
|
||||||
|
return {"bytes": data}
|
||||||
|
else:
|
||||||
|
return {"text": data}
|
||||||
|
|
||||||
|
async def send_json(self, obj):
|
||||||
|
import json
|
||||||
|
|
||||||
|
await self._ws.send(json.dumps(obj, ensure_ascii=False))
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
await self._ws.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TritonGrpcClient:
|
||||||
|
def __init__(self, triton_url: str):
|
||||||
|
self.triton_url = triton_url
|
||||||
|
|
||||||
|
"""
|
||||||
|
Owns only: audio -> chunking -> Triton -> queue -> streaming.
|
||||||
|
Keeps producing even if the HTTP/WS request disconnects.
|
||||||
|
MinIO/DB/file finalize stays outside via callbacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ---------- SSE: return async generator ----------
|
||||||
|
def event_stream_from_bytes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
raw: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
on_final_text: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||||
|
on_error: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
async def _gen() -> AsyncGenerator[str, None]:
|
||||||
|
if not raw:
|
||||||
|
raise ValueError("Uploaded file is empty")
|
||||||
|
|
||||||
|
tmp_path = self._write_temp_file(raw, filename)
|
||||||
|
queue: asyncio.Queue[dict[str, str | None]] = asyncio.Queue()
|
||||||
|
drop_event = asyncio.Event() # set on disconnect → stop emitting only
|
||||||
|
|
||||||
|
# spawn producer and DO NOT await it here; it lives independently
|
||||||
|
asyncio.create_task(
|
||||||
|
self._produce_transcripts(tmp_path, queue, on_final_text, on_error, drop_event),
|
||||||
|
name=f"triton-producer-{uuid.uuid4().hex[:8]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[SSE] Client connected", flush=True)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await queue.get()
|
||||||
|
if msg.get("event") == "done":
|
||||||
|
print("[SSE] Job finished → closing stream", flush=True)
|
||||||
|
break
|
||||||
|
yield f"data: {json.dumps(msg, ensure_ascii=False)}\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# request closed; keep producer alive, just stop emitting
|
||||||
|
print("[SSE] Client disconnected (background continues)", flush=True)
|
||||||
|
drop_event.set()
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
print("[SSE] event_stream END", flush=True)
|
||||||
|
|
||||||
|
return _gen()
|
||||||
|
|
||||||
|
# ---------- WebSocket: same producer, push over WS ----------
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _open_triton(self):
|
||||||
|
client = grpcclient.InferenceServerClient(self.triton_url, verbose=False)
|
||||||
|
try:
|
||||||
|
yield client
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
async def _produce_transcripts(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
queue: "asyncio.Queue[dict[str, str | None]]",
|
||||||
|
on_final_text: Optional[Callable[[str], Awaitable[None]]],
|
||||||
|
on_error: Optional[Callable[[str], Awaitable[None]]],
|
||||||
|
drop_event: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
|
print("[BG] Started producer", flush=True)
|
||||||
|
last_msg: dict[str, str] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("[BG] Entering stream_transcript loop", flush=True)
|
||||||
|
async for last_msg in self._stream_transcript(str(tmp_path)):
|
||||||
|
if not drop_event.is_set():
|
||||||
|
await queue.put(last_msg)
|
||||||
|
|
||||||
|
print("[BG] stream_transcript finished", flush=True)
|
||||||
|
final_text = (last_msg or {}).get("text", "").strip()
|
||||||
|
|
||||||
|
if on_final_text:
|
||||||
|
with suppress(Exception):
|
||||||
|
await on_final_text(final_text)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[BG] EXCEPTION: {exc!r}", flush=True)
|
||||||
|
if on_error:
|
||||||
|
with suppress(Exception):
|
||||||
|
await on_error(str(exc))
|
||||||
|
if not drop_event.is_set():
|
||||||
|
await queue.put({"event": "error", "detail": str(exc)})
|
||||||
|
finally:
|
||||||
|
print("[BG] Cleaning up temp file", flush=True)
|
||||||
|
with suppress(FileNotFoundError):
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
if not drop_event.is_set():
|
||||||
|
await queue.put({"event": "done"})
|
||||||
|
print("[BG] producer END", flush=True)
|
||||||
|
|
||||||
|
# Replace your existing websocket_stream_from_websocket with this version
|
||||||
|
async def websocket_stream_from_websocket(
|
||||||
|
self,
|
||||||
|
websocket,
|
||||||
|
*,
|
||||||
|
filename: str = "stream",
|
||||||
|
content_type: Optional[str] = None,
|
||||||
|
on_final_text: Optional[Callable[..., Awaitable[None]]] = None,
|
||||||
|
on_error: Optional[Callable[[str], Awaitable[None]]] = None,
|
||||||
|
accept_in_client: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Supports both:
|
||||||
|
- Starlette/FastAPI WebSocket objects (they expose .receive(), .send_json(), .accept())
|
||||||
|
- `websockets` WebSocketServerProtocol (they expose .recv(), .send(), .close())
|
||||||
|
The adapter wraps the latter so the rest of your existing logic can stay unchanged.
|
||||||
|
"""
|
||||||
|
# Lazy import here so file-level imports need not change
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
# If this is a websockets (WebSocketServerProtocol) instance, wrap it
|
||||||
|
# Heuristic: Starlette has .receive(); websockets has .recv()
|
||||||
|
if not hasattr(websocket, "receive") and hasattr(websocket, "recv"):
|
||||||
|
websocket = _WebsocketsAdapter(websocket)
|
||||||
|
|
||||||
|
# If caller requested server-side accept for ASGI websockets, call it.
|
||||||
|
# For websockets adapter accept() is a no-op.
|
||||||
|
if accept_in_client:
|
||||||
|
# In FastAPI typical pattern is server calls accept(); keep that behavior
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# We'll append all raw bytes to a temp file so the endpoint can upload later in the finalizer
|
||||||
|
src_suffix = Path(filename).suffix or ".bin"
|
||||||
|
src_path = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}{src_suffix}"
|
||||||
|
f_src = src_path.open("wb")
|
||||||
|
|
||||||
|
# Background pipeline control
|
||||||
|
queue: asyncio.Queue[dict[str, str | None]] = asyncio.Queue()
|
||||||
|
drop_event = asyncio.Event() # if client disconnects, stop SENDING but keep producing
|
||||||
|
|
||||||
|
async def _call_final_cb(final_text: str) -> None:
|
||||||
|
if not on_final_text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Try callback(final_text, source_audio_path) first, fallback to (final_text)
|
||||||
|
if on_final_text.__code__.co_argcount >= 2:
|
||||||
|
await on_final_text(final_text, src_path)
|
||||||
|
else:
|
||||||
|
await on_final_text(final_text)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def recv_frames_and_transcode(proc):
|
||||||
|
"""
|
||||||
|
Read frames using the (possibly-adapted) `websocket.receive()` and write to ffmpeg stdin;
|
||||||
|
close stdin when we get {"event":"end"} or disconnect.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
msg = await websocket.receive()
|
||||||
|
if "bytes" in msg and msg["bytes"] is not None:
|
||||||
|
chunk = msg["bytes"]
|
||||||
|
f_src.write(chunk)
|
||||||
|
proc.stdin.write(chunk) # type: ignore[attr-defined]
|
||||||
|
await proc.stdin.drain() # type: ignore[attr-defined]
|
||||||
|
elif "text" in msg and msg["text"] is not None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(msg["text"])
|
||||||
|
if payload.get("event") == "end":
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
# ignore non-JSON text frames
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# ignore pings/other control frames
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
except Exception:
|
||||||
|
# If underlying connection closed abruptly, just stop receiving
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
f_src.flush()
|
||||||
|
f_src.close()
|
||||||
|
with suppress(Exception):
|
||||||
|
# Some implementations use close(); others have no stdin to close
|
||||||
|
proc.stdin.close() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
async def transcribe_from_ffmpeg_stdout(proc):
|
||||||
|
"""
|
||||||
|
Read float32 PCM from ffmpeg stdout, chunk on-the-fly, call Triton per chunk,
|
||||||
|
and push partial/final messages to the queue.
|
||||||
|
"""
|
||||||
|
# Triton session + chunk sizes
|
||||||
|
async with self._open_triton() as client:
|
||||||
|
first_sz, chunk_sz = await self._get_chunk_sizes(client, MODEL_NAME)
|
||||||
|
seq_id = uuid.uuid4().int & 0x7FFF_FFFF_FFFF_FFFF
|
||||||
|
full_tx = ""
|
||||||
|
have_sent_any = False
|
||||||
|
need = first_sz # samples needed for the next chunk
|
||||||
|
|
||||||
|
# PCM sample buffer (float32)
|
||||||
|
buf = np.empty(0, dtype=np.float32)
|
||||||
|
|
||||||
|
async def infer_one(raw: np.ndarray, eff_len: int, is_first: bool, is_last: bool):
|
||||||
|
nonlocal full_tx
|
||||||
|
wav_np = raw[None, :] # (1, T)
|
||||||
|
len_np = np.array([[eff_len]], np.int32)
|
||||||
|
inp_wav = grpcclient.InferInput(INPUT_WAV_TENSOR, wav_np.shape, np_to_triton_dtype(np.float32))
|
||||||
|
inp_len = grpcclient.InferInput(INPUT_LEN_TENSOR, len_np.shape, np_to_triton_dtype(np.int32))
|
||||||
|
inp_wav.set_data_from_numpy(wav_np)
|
||||||
|
inp_len.set_data_from_numpy(len_np)
|
||||||
|
outs = [grpcclient.InferRequestedOutput(OUTPUT_TEXT_TENSOR)]
|
||||||
|
|
||||||
|
resp = await client.infer(
|
||||||
|
MODEL_NAME,
|
||||||
|
inputs=[inp_wav, inp_len],
|
||||||
|
outputs=outs,
|
||||||
|
sequence_id=seq_id,
|
||||||
|
sequence_start=is_first,
|
||||||
|
sequence_end=is_last,
|
||||||
|
)
|
||||||
|
txt = b" ".join(resp.as_numpy(OUTPUT_TEXT_TENSOR)).decode().strip()
|
||||||
|
if not txt:
|
||||||
|
return None
|
||||||
|
delta = txt[len(full_tx) :] if txt.startswith(full_tx) else txt
|
||||||
|
full_tx = txt
|
||||||
|
return delta or None
|
||||||
|
|
||||||
|
# Read ffmpeg stdout in bytes; convert to float32
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = await proc.stdout.read(8192 * 4) # 8192 samples (float32)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
# append decoded samples
|
||||||
|
new = np.frombuffer(chunk, dtype=np.float32)
|
||||||
|
if new.size == 0:
|
||||||
|
continue
|
||||||
|
if buf.size == 0:
|
||||||
|
buf = new
|
||||||
|
else:
|
||||||
|
buf = np.concatenate((buf, new), axis=0)
|
||||||
|
|
||||||
|
# while enough samples for the next piece, send it
|
||||||
|
while buf.size >= need:
|
||||||
|
take = need
|
||||||
|
piece = buf[:take]
|
||||||
|
buf = buf[take:]
|
||||||
|
is_first = not have_sent_any
|
||||||
|
have_sent_any = True
|
||||||
|
|
||||||
|
delta = await infer_one(piece, take, is_first=is_first, is_last=False)
|
||||||
|
if delta and not drop_event.is_set():
|
||||||
|
await queue.put({"time": time.time(), "text": delta, "is_final": False})
|
||||||
|
|
||||||
|
# after first, normal chunk size
|
||||||
|
need = chunk_sz
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# End of stream: flush any remainder and a zero-length piece if configured
|
||||||
|
if buf.size > 0:
|
||||||
|
# pad to chunk_sz for model framing, but eff_len is the real (short) length
|
||||||
|
eff = int(buf.size)
|
||||||
|
pad = np.zeros(chunk_sz, dtype=np.float32)
|
||||||
|
pad[:eff] = buf[:eff]
|
||||||
|
delta = await infer_one(pad, eff, is_first=not have_sent_any, is_last=False)
|
||||||
|
if delta and not drop_event.is_set():
|
||||||
|
await queue.put({"time": time.time(), "text": delta, "is_final": False})
|
||||||
|
have_sent_any = True
|
||||||
|
|
||||||
|
if ZERO_PAD_REQUEST_CONTENT:
|
||||||
|
# zero-length "flush" chunk
|
||||||
|
zero = np.zeros(0, dtype=np.float32)
|
||||||
|
await infer_one(zero, 0, is_first=not have_sent_any, is_last=True)
|
||||||
|
else:
|
||||||
|
# If not sending the explicit flush, still mark last by sending empty with last=True
|
||||||
|
zero = np.zeros(0, dtype=np.float32)
|
||||||
|
await infer_one(zero, 0, is_first=False, is_last=True)
|
||||||
|
|
||||||
|
# Emit final full transcript as a message, like your file-based path
|
||||||
|
if not drop_event.is_set():
|
||||||
|
await queue.put({"time": time.time(), "text": full_tx.strip(), "is_final": True})
|
||||||
|
|
||||||
|
# Call user finalizer
|
||||||
|
await _call_final_cb(full_tx.strip())
|
||||||
|
|
||||||
|
# signal done to sender
|
||||||
|
if not drop_event.is_set():
|
||||||
|
await queue.put({"event": "done"})
|
||||||
|
|
||||||
|
async def send_messages():
|
||||||
|
HEARTBEAT_SECS = 10
|
||||||
|
print("[WS] Client connected", flush=True)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# wait for a real message up to HEARTBEAT_SECS
|
||||||
|
msg = await asyncio.wait_for(queue.get(), timeout=HEARTBEAT_SECS)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# no real message → send heartbeat
|
||||||
|
hb = {"event": "heartbeat", "t": time.time()}
|
||||||
|
await websocket.send_json(hb)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if msg.get("event") == "done":
|
||||||
|
print("[WS] Job finished → closing socket", flush=True)
|
||||||
|
break
|
||||||
|
|
||||||
|
await websocket.send_json(msg)
|
||||||
|
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
print("[WS] Client disconnected (background continues)", flush=True)
|
||||||
|
drop_event.set()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
print("[WS] Client cancelled (background continues)", flush=True)
|
||||||
|
drop_event.set()
|
||||||
|
finally:
|
||||||
|
with suppress(Exception):
|
||||||
|
await websocket.close()
|
||||||
|
print("[WS] send_messages END", flush=True)
|
||||||
|
|
||||||
|
# The remainder of this function is unchanged from your original implementation:
|
||||||
|
# - transcribe_from_ffmpeg_stdout(proc)
|
||||||
|
# - send_messages()
|
||||||
|
# - ffmpeg process startup
|
||||||
|
# - task creation + gather + cleanup
|
||||||
|
#
|
||||||
|
# To keep this patch minimal I re-use your original nested function bodies as-is.
|
||||||
|
# Copy the original nested functions transcribe_from_ffmpeg_stdout and send_messages
|
||||||
|
# exactly as you had them (they will reference `websocket`, `queue`, `drop_event`,
|
||||||
|
# `_open_triton`, etc.). Below I paste them unchanged but with no modifications so
|
||||||
|
# you can drop them in place.
|
||||||
|
#
|
||||||
|
# ---- paste your original nested functions here exactly (no changes) ----
|
||||||
|
|
||||||
|
# ----- Start ffmpeg (stdin bytes -> stdout float32 PCM @ 16k mono)
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
"ffmpeg",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
str(SAMPLE_RATE_HZ),
|
||||||
|
"-f",
|
||||||
|
"f32le",
|
||||||
|
"pipe:1",
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def drain_ffmpeg_stderr(proc):
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
line = await proc.stderr.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
# You can log it if needed:
|
||||||
|
# print(f"[ffmpeg] {line.decode().rstrip()}", flush=True)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stderr_task = asyncio.create_task(drain_ffmpeg_stderr(proc), name="ffmpeg-stderr")
|
||||||
|
|
||||||
|
# Run 3 tasks concurrently:
|
||||||
|
# - recv frames & feed ffmpeg
|
||||||
|
# - read pcm & call Triton
|
||||||
|
# - send queue messages to client
|
||||||
|
# Note: `transcribe_from_ffmpeg_stdout` and `send_messages` are your original nested functions.
|
||||||
|
# Keep them unchanged and ensure they reference `websocket` only for send_json/receive (adapter handles it).
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(recv_frames_and_transcode(proc), name="ws-recv"),
|
||||||
|
asyncio.create_task(transcribe_from_ffmpeg_stdout(proc), name="ws-triton"),
|
||||||
|
asyncio.create_task(send_messages(), name="ws-send"),
|
||||||
|
stderr_task,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
finally:
|
||||||
|
for t in tasks:
|
||||||
|
if not t.done():
|
||||||
|
t.cancel()
|
||||||
|
with suppress(Exception):
|
||||||
|
proc.terminate()
|
||||||
|
with suppress(Exception):
|
||||||
|
await proc.wait()
|
||||||
|
with suppress(Exception):
|
||||||
|
if not f_src.closed:
|
||||||
|
f_src.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_temp_file(raw: bytes, filename: str) -> Path:
|
||||||
|
tmp = Path(tempfile.gettempdir()) / f"{uuid.uuid4()}{Path(filename).suffix}"
|
||||||
|
tmp.write_bytes(raw)
|
||||||
|
print(f"[REQ] Temp file written at {tmp}", flush=True)
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _pcm_int16_to_float32(x: np.ndarray) -> np.ndarray:
|
||||||
|
return (x.astype(np.float32) / 32768.0).clip(-1.0, 1.0)
|
||||||
|
|
||||||
|
def _load_audio(self, path: str, target_sr: int = SAMPLE_RATE_HZ):
|
||||||
|
audio = AudioSegment.from_file(path)
|
||||||
|
audio = audio.set_channels(1).set_frame_rate(target_sr).set_sample_width(2)
|
||||||
|
pcm_int16 = np.frombuffer(audio.raw_data, dtype=np.int16)
|
||||||
|
return pcm_int16, target_sr
|
||||||
|
|
||||||
|
async def _get_chunk_sizes(self, client: grpcclient.InferenceServerClient, model: str) -> Tuple[int, int]:
|
||||||
|
try:
|
||||||
|
cfg = await client.get_model_config(model, as_json=True)
|
||||||
|
params = {p["key"]: p["value"]["string_param"] for p in cfg.get("parameters", [])}
|
||||||
|
first_sec = float(params.get("chunk_size_first", params.get("chunk_size", "0.465")))
|
||||||
|
norm_sec = float(params.get("chunk_size", "0.32"))
|
||||||
|
except Exception:
|
||||||
|
first_sec, norm_sec = 0.465, 0.32
|
||||||
|
return int(first_sec * SAMPLE_RATE_HZ), int(norm_sec * SAMPLE_RATE_HZ)
|
||||||
|
|
||||||
|
async def _stream_transcript(self, path: str) -> AsyncGenerator[dict, None]:
|
||||||
|
"""
|
||||||
|
Read audio from `path`, chunk it and stream to Triton, yielding partial/final messages.
|
||||||
|
|
||||||
|
Yields dicts of the form:
|
||||||
|
{"time": <unix-time>, "text": "<delta text>", "is_final": False}
|
||||||
|
and finally:
|
||||||
|
{"time": <unix-time>, "text": "<full transcript>", "is_final": True}
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
# Load audio (int16 PCM) and convert to float32 in [-1, 1]
|
||||||
|
pcm_int16, sr = self._load_audio(path, target_sr=SAMPLE_RATE_HZ)
|
||||||
|
if pcm_int16.size == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
wav = self._pcm_int16_to_float32(pcm_int16) # float32 1-D numpy array
|
||||||
|
|
||||||
|
# Triton client + chunk sizes
|
||||||
|
async with self._open_triton() as client:
|
||||||
|
first_sz, chunk_sz = await self._get_chunk_sizes(client, MODEL_NAME)
|
||||||
|
seq_id = uuid.uuid4().int & 0x7FFF_FFFF_FFFF_FFFF
|
||||||
|
|
||||||
|
full_tx = "" # accumulated full transcript text
|
||||||
|
have_sent_any = False
|
||||||
|
|
||||||
|
async def infer_one(raw: np.ndarray, eff_len: int, is_first: bool, is_last: bool):
|
||||||
|
"""
|
||||||
|
Send one request to Triton and return the delta text (or None).
|
||||||
|
raw: 1-D float32 numpy array length == raw.shape[0] (should be >= eff_len; padded if needed)
|
||||||
|
eff_len: number of valid samples in raw (int)
|
||||||
|
"""
|
||||||
|
nonlocal full_tx
|
||||||
|
wav_np = raw[None, :] # shape (1, T)
|
||||||
|
len_np = np.array([[eff_len]], np.int32)
|
||||||
|
inp_wav = grpcclient.InferInput(INPUT_WAV_TENSOR, wav_np.shape, np_to_triton_dtype(np.float32))
|
||||||
|
inp_len = grpcclient.InferInput(INPUT_LEN_TENSOR, len_np.shape, np_to_triton_dtype(np.int32))
|
||||||
|
inp_wav.set_data_from_numpy(wav_np)
|
||||||
|
inp_len.set_data_from_numpy(len_np)
|
||||||
|
outs = [grpcclient.InferRequestedOutput(OUTPUT_TEXT_TENSOR)]
|
||||||
|
|
||||||
|
resp = await client.infer(
|
||||||
|
MODEL_NAME,
|
||||||
|
inputs=[inp_wav, inp_len],
|
||||||
|
outputs=outs,
|
||||||
|
sequence_id=seq_id,
|
||||||
|
sequence_start=is_first,
|
||||||
|
sequence_end=is_last,
|
||||||
|
)
|
||||||
|
# join parts if model returned multiple tensors; decode bytes -> str
|
||||||
|
txt = b" ".join(resp.as_numpy(OUTPUT_TEXT_TENSOR)).decode().strip()
|
||||||
|
if not txt:
|
||||||
|
return None
|
||||||
|
delta = txt[len(full_tx) :] if txt.startswith(full_tx) else txt
|
||||||
|
full_tx = txt
|
||||||
|
return delta or None
|
||||||
|
|
||||||
|
# iterate over wav in chunks
|
||||||
|
T = wav.shape[0]
|
||||||
|
offset = 0
|
||||||
|
need = first_sz
|
||||||
|
# If first_sz is 0 for some reason, fall back to chunk_sz
|
||||||
|
if need <= 0:
|
||||||
|
need = chunk_sz
|
||||||
|
|
||||||
|
try:
|
||||||
|
while offset < T:
|
||||||
|
take = min(need, T - offset)
|
||||||
|
piece = wav[offset : offset + take]
|
||||||
|
|
||||||
|
# If piece is shorter than need (shouldn't happen except maybe for first),
|
||||||
|
# pad to `need` as model may expect framing; eff_len tracks real length.
|
||||||
|
eff = int(piece.size)
|
||||||
|
if eff < need:
|
||||||
|
pad = np.zeros(need, dtype=np.float32)
|
||||||
|
pad[:eff] = piece
|
||||||
|
to_send = pad
|
||||||
|
else:
|
||||||
|
to_send = piece
|
||||||
|
|
||||||
|
is_first = not have_sent_any
|
||||||
|
have_sent_any = True
|
||||||
|
|
||||||
|
# Not last unless remaining after this is zero
|
||||||
|
is_last = False
|
||||||
|
|
||||||
|
delta = await infer_one(to_send, eff, is_first=is_first, is_last=is_last)
|
||||||
|
if delta:
|
||||||
|
yield {"time": time.time(), "text": delta, "is_final": False}
|
||||||
|
|
||||||
|
offset += take
|
||||||
|
need = chunk_sz # after first, normal chunk size
|
||||||
|
|
||||||
|
# End of stream: if there is leftover silence/padding behavior handled above.
|
||||||
|
# After all real chunks sent, optionally flush remainder (zero-length) as last=True.
|
||||||
|
if ZERO_PAD_REQUEST_CONTENT:
|
||||||
|
# send an explicit flush chunk (zero-length) with sequence_end=True
|
||||||
|
zero = np.zeros(0, dtype=np.float32)
|
||||||
|
# When sending zero-length, eff_len = 0. Mark is_first True only if never sent anything.
|
||||||
|
await infer_one(zero, 0, is_first=(not have_sent_any), is_last=True)
|
||||||
|
else:
|
||||||
|
# send empty with last=True but not as first
|
||||||
|
zero = np.zeros(0, dtype=np.float32)
|
||||||
|
await infer_one(zero, 0, is_first=False, is_last=True)
|
||||||
|
|
||||||
|
# Emit final full transcript
|
||||||
|
yield {"time": time.time(), "text": full_tx.strip(), "is_final": True}
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# nothing specific to clean in this generator; Triton client closed by context manager
|
||||||
|
with suppress(Exception):
|
||||||
|
pass
|
||||||
@ -1,2 +1,29 @@
|
|||||||
et_xmlfile==2.0.0
|
aiohappyeyeballs==2.6.1
|
||||||
openpyxl==3.1.5
|
aiohttp==3.13.0
|
||||||
|
aiosignal==1.4.0
|
||||||
|
async-timeout==5.0.1
|
||||||
|
attrs==25.4.0
|
||||||
|
Brotli==1.1.0
|
||||||
|
certifi==2025.10.5
|
||||||
|
frozenlist==1.8.0
|
||||||
|
gevent==25.9.1
|
||||||
|
geventhttpclient==2.3.4
|
||||||
|
greenlet==3.2.4
|
||||||
|
grpcio==1.67.1
|
||||||
|
idna==3.11
|
||||||
|
multidict==6.7.0
|
||||||
|
numpy==2.2.6
|
||||||
|
packaging==25.0
|
||||||
|
perf-analyzer==2.59.1
|
||||||
|
propcache==0.4.1
|
||||||
|
protobuf==5.29.5
|
||||||
|
pydub==0.25.1
|
||||||
|
python-dotenv==1.1.1
|
||||||
|
python-rapidjson==1.21
|
||||||
|
tritonclient==2.61.0
|
||||||
|
typing_extensions==4.15.0
|
||||||
|
urllib3==2.5.0
|
||||||
|
websockets==15.0.1
|
||||||
|
yarl==1.22.0
|
||||||
|
zope.event==6.0
|
||||||
|
zope.interface==8.0.1
|
||||||
|
|||||||
30
setup.py
30
setup.py
@ -1,27 +1,23 @@
|
|||||||
# setup.py
|
|
||||||
|
|
||||||
from setuptools import setup, find_packages
|
from setuptools import setup, find_packages
|
||||||
import os
|
|
||||||
|
|
||||||
# Read requirements from requirements.txt
|
|
||||||
with open("requirements.txt") as f:
|
with open("requirements.txt") as f:
|
||||||
requirements = f.read().splitlines()
|
requirements = f.read().splitlines()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='bi_utils',
|
name="asr_client",
|
||||||
version='0.1.0',
|
version="0.1.0",
|
||||||
description='A Bi Utils Code',
|
description="extract text from audio file",
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
author='BI',
|
author="BI",
|
||||||
author_email='BI@bi.ir',
|
author_email="BI@bi.ir",
|
||||||
url='https://git.d.aiengines.ir/bi/bi_utils', # Replace with your repository URL
|
url="https://git.d.aiengines.ir/bi/asr_triton_client.git",
|
||||||
packages=find_packages(),
|
packages=find_packages(),
|
||||||
install_requires=requirements, # Use the requirements from the file
|
install_requires=requirements,
|
||||||
include_package_data=True, # Ensure non-code files are included if necessary
|
include_package_data=True,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
'Programming Language :: Python :: 3',
|
"Programming Language :: Python :: 3",
|
||||||
'License :: OSI Approved :: MIT License', # Change if using a different license
|
"License :: OSI Approved :: MIT License",
|
||||||
'Operating System :: OS Independent',
|
"Operating System :: OS Independent",
|
||||||
],
|
],
|
||||||
python_requires='>=3.7',
|
python_requires=">=3.10",
|
||||||
)
|
)
|
||||||
|
|||||||
25
venv310/LICENSE.txt
Normal file
25
venv310/LICENSE.txt
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Copyright (c) 2020, NVIDIA CORPORATION. All rights reserved.
|
||||||
|
#
|
||||||
|
# Redistribution and use in source and binary forms, with or without
|
||||||
|
# modification, are permitted provided that the following conditions
|
||||||
|
# are met:
|
||||||
|
# * Redistributions of source code must retain the above copyright
|
||||||
|
# notice, this list of conditions and the following disclaimer.
|
||||||
|
# * Redistributions in binary form must reproduce the above copyright
|
||||||
|
# notice, this list of conditions and the following disclaimer in the
|
||||||
|
# documentation and/or other materials provided with the distribution.
|
||||||
|
# * Neither the name of NVIDIA CORPORATION nor the names of its
|
||||||
|
# contributors may be used to endorse or promote products derived
|
||||||
|
# from this software without specific prior written permission.
|
||||||
|
#
|
||||||
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY
|
||||||
|
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
|
||||||
|
# OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
247
venv310/bin/Activate.ps1
Normal file
247
venv310/bin/Activate.ps1
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Activate a Python virtual environment for the current PowerShell session.
|
||||||
|
|
||||||
|
.Description
|
||||||
|
Pushes the python executable for a virtual environment to the front of the
|
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||||
|
in a Python virtual environment. Makes use of the command line switches as
|
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||||
|
|
||||||
|
.Parameter VenvDir
|
||||||
|
Path to the directory that contains the virtual environment to activate. The
|
||||||
|
default value for this is the parent of the directory that the Activate.ps1
|
||||||
|
script is located within.
|
||||||
|
|
||||||
|
.Parameter Prompt
|
||||||
|
The prompt prefix to display when this virtual environment is activated. By
|
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Verbose
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and shows extra information about the activation as it executes.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||||
|
Activates the Python virtual environment located in the specified location.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Prompt "MyPython"
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and prefixes the current prompt with the specified string (surrounded in
|
||||||
|
parentheses) while the virtual environment is active.
|
||||||
|
|
||||||
|
.Notes
|
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell
|
||||||
|
command:
|
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
For more information on Execution Policies:
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||||
|
|
||||||
|
#>
|
||||||
|
Param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$VenvDir,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$Prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #>
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Remove all shell session elements added by the Activate script, including the
|
||||||
|
addition of the virtual environment's Python executable from the beginning of
|
||||||
|
the PATH variable.
|
||||||
|
|
||||||
|
.Parameter NonDestructive
|
||||||
|
If present, do not remove this function from the global namespace for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
#>
|
||||||
|
function global:deactivate ([switch]$NonDestructive) {
|
||||||
|
# Revert to original values
|
||||||
|
|
||||||
|
# The prior prompt:
|
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PYTHONHOME:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PATH:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether:
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested:
|
||||||
|
if (-not $NonDestructive) {
|
||||||
|
Remove-Item -Path function:deactivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Description
|
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||||
|
given folder, and returns them in a map.
|
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||||
|
then it is considered a `key = value` line. The left hand string is the key,
|
||||||
|
the right hand is the value.
|
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is
|
||||||
|
stripped from the value before being captured.
|
||||||
|
|
||||||
|
.Parameter ConfigDir
|
||||||
|
Path to the directory that contains the `pyvenv.cfg` file.
|
||||||
|
#>
|
||||||
|
function Get-PyVenvConfig(
|
||||||
|
[String]
|
||||||
|
$ConfigDir
|
||||||
|
) {
|
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found.
|
||||||
|
$pyvenvConfig = @{ }
|
||||||
|
|
||||||
|
if ($pyvenvConfigPath) {
|
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines"
|
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object {
|
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2
|
||||||
|
if ($keyval[0] -and $keyval[1]) {
|
||||||
|
$val = $keyval[1]
|
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value.
|
||||||
|
if ("'""".Contains($val.Substring(0, 1))) {
|
||||||
|
$val = $val.Substring(1, $val.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val
|
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pyvenvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #>
|
||||||
|
|
||||||
|
# Determine the containing directory of this script
|
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||||
|
# First, get the location of the virtual environment, it might not be
|
||||||
|
# VenvExecDir if specified on the command line.
|
||||||
|
if ($VenvDir) {
|
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||||
|
Write-Verbose "VenvDir=$VenvDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||||
|
# as `prompt`.
|
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or
|
||||||
|
# just use the name of the virtual environment folder.
|
||||||
|
if ($Prompt) {
|
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||||
|
$Prompt = $pyvenvCfg['prompt'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'"
|
||||||
|
Write-Verbose "VenvDir='$VenvDir'"
|
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the
|
||||||
|
# deactivate function in place.
|
||||||
|
deactivate -nondestructive
|
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||||
|
# that there is an activated venv.
|
||||||
|
$env:VIRTUAL_ENV = $VenvDir
|
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'"
|
||||||
|
|
||||||
|
# Set the prompt to include the env name
|
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||||
|
|
||||||
|
function global:prompt {
|
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||||
|
_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear PYTHONHOME
|
||||||
|
if (Test-Path -Path Env:PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
Remove-Item -Path Env:PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the venv to the PATH
|
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||||
69
venv310/bin/activate
Normal file
69
venv310/bin/activate
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# This file must be used with "source bin/activate" *from bash*
|
||||||
|
# you cannot run it directly
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
# reset old environment variables
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||||
|
export PATH
|
||||||
|
unset _OLD_VIRTUAL_PATH
|
||||||
|
fi
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||||
|
export PYTHONHOME
|
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||||
|
export PS1
|
||||||
|
unset _OLD_VIRTUAL_PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset VIRTUAL_ENV
|
||||||
|
unset VIRTUAL_ENV_PROMPT
|
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||||
|
# Self destruct!
|
||||||
|
unset -f deactivate
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# unset irrelevant variables
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
VIRTUAL_ENV=/home/azhidev/bagher/packages/asr_triton_client/venv310
|
||||||
|
export VIRTUAL_ENV
|
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# unset PYTHONHOME if set
|
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||||
|
unset PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||||
|
PS1='(venv310) '"${PS1:-}"
|
||||||
|
export PS1
|
||||||
|
VIRTUAL_ENV_PROMPT='(venv310) '
|
||||||
|
export VIRTUAL_ENV_PROMPT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
26
venv310/bin/activate.csh
Normal file
26
venv310/bin/activate.csh
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||||
|
# You cannot run it directly.
|
||||||
|
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||||
|
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||||
|
|
||||||
|
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
setenv VIRTUAL_ENV /home/azhidev/bagher/packages/asr_triton_client/venv310
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||||
|
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||||
|
|
||||||
|
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||||
|
set prompt = '(venv310) '"$prompt"
|
||||||
|
setenv VIRTUAL_ENV_PROMPT '(venv310) '
|
||||||
|
endif
|
||||||
|
|
||||||
|
alias pydoc python -m pydoc
|
||||||
|
|
||||||
|
rehash
|
||||||
69
venv310/bin/activate.fish
Normal file
69
venv310/bin/activate.fish
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||||
|
# (https://fishshell.com/); you cannot run it directly.
|
||||||
|
|
||||||
|
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||||
|
# reset old environment variables
|
||||||
|
if test -n "$_OLD_VIRTUAL_PATH"
|
||||||
|
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||||
|
set -e _OLD_VIRTUAL_PATH
|
||||||
|
end
|
||||||
|
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||||
|
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||||
|
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||||
|
# prevents error when using nested fish instances (Issue #93858)
|
||||||
|
if functions -q _old_fish_prompt
|
||||||
|
functions -e fish_prompt
|
||||||
|
functions -c _old_fish_prompt fish_prompt
|
||||||
|
functions -e _old_fish_prompt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
set -e VIRTUAL_ENV
|
||||||
|
set -e VIRTUAL_ENV_PROMPT
|
||||||
|
if test "$argv[1]" != "nondestructive"
|
||||||
|
# Self-destruct!
|
||||||
|
functions -e deactivate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
set -gx VIRTUAL_ENV /home/azhidev/bagher/packages/asr_triton_client/venv310
|
||||||
|
|
||||||
|
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||||
|
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||||
|
|
||||||
|
# Unset PYTHONHOME if set.
|
||||||
|
if set -q PYTHONHOME
|
||||||
|
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||||
|
set -e PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||||
|
# fish uses a function instead of an env var to generate the prompt.
|
||||||
|
|
||||||
|
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||||
|
functions -c fish_prompt _old_fish_prompt
|
||||||
|
|
||||||
|
# With the original prompt function renamed, we can override with our own.
|
||||||
|
function fish_prompt
|
||||||
|
# Save the return status of the last command.
|
||||||
|
set -l old_status $status
|
||||||
|
|
||||||
|
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||||
|
printf "%s%s%s" (set_color 4B8BBE) '(venv310) ' (set_color normal)
|
||||||
|
|
||||||
|
# Restore the return status of the previous command.
|
||||||
|
echo "exit $old_status" | .
|
||||||
|
# Output the original/"old" prompt.
|
||||||
|
_old_fish_prompt
|
||||||
|
end
|
||||||
|
|
||||||
|
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||||
|
set -gx VIRTUAL_ENV_PROMPT '(venv310) '
|
||||||
|
end
|
||||||
8
venv310/bin/dotenv
Executable file
8
venv310/bin/dotenv
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dotenv.__main__ import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli())
|
||||||
8
venv310/bin/f2py
Executable file
8
venv310/bin/f2py
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from numpy.f2py.f2py2e import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
venv310/bin/numpy-config
Executable file
8
venv310/bin/numpy-config
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from numpy._configtool import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
venv310/bin/perf_analyzer
Executable file
8
venv310/bin/perf_analyzer
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from perf_analyzer.cli import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
venv310/bin/pip
Executable file
8
venv310/bin/pip
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
venv310/bin/pip3
Executable file
8
venv310/bin/pip3
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
8
venv310/bin/pip3.10
Executable file
8
venv310/bin/pip3.10
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
1
venv310/bin/python
Symbolic link
1
venv310/bin/python
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
python3.10
|
||||||
1
venv310/bin/python3
Symbolic link
1
venv310/bin/python3
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
python3.10
|
||||||
1
venv310/bin/python3.10
Symbolic link
1
venv310/bin/python3.10
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3.10
|
||||||
8
venv310/bin/websockets
Executable file
8
venv310/bin/websockets
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/home/azhidev/bagher/packages/asr_triton_client/venv310/bin/python3.10
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from websockets.cli import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
164
venv310/include/site/python3.10/greenlet/greenlet.h
Normal file
164
venv310/include/site/python3.10/greenlet/greenlet.h
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
|
||||||
|
|
||||||
|
/* Greenlet object interface */
|
||||||
|
|
||||||
|
#ifndef Py_GREENLETOBJECT_H
|
||||||
|
#define Py_GREENLETOBJECT_H
|
||||||
|
|
||||||
|
|
||||||
|
#include <Python.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* This is deprecated and undocumented. It does not change. */
|
||||||
|
#define GREENLET_VERSION "1.0.0"
|
||||||
|
|
||||||
|
#ifndef GREENLET_MODULE
|
||||||
|
#define implementation_ptr_t void*
|
||||||
|
#endif
|
||||||
|
|
||||||
|
typedef struct _greenlet {
|
||||||
|
PyObject_HEAD
|
||||||
|
PyObject* weakreflist;
|
||||||
|
PyObject* dict;
|
||||||
|
implementation_ptr_t pimpl;
|
||||||
|
} PyGreenlet;
|
||||||
|
|
||||||
|
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
|
||||||
|
|
||||||
|
|
||||||
|
/* C API functions */
|
||||||
|
|
||||||
|
/* Total number of symbols that are exported */
|
||||||
|
#define PyGreenlet_API_pointers 12
|
||||||
|
|
||||||
|
#define PyGreenlet_Type_NUM 0
|
||||||
|
#define PyExc_GreenletError_NUM 1
|
||||||
|
#define PyExc_GreenletExit_NUM 2
|
||||||
|
|
||||||
|
#define PyGreenlet_New_NUM 3
|
||||||
|
#define PyGreenlet_GetCurrent_NUM 4
|
||||||
|
#define PyGreenlet_Throw_NUM 5
|
||||||
|
#define PyGreenlet_Switch_NUM 6
|
||||||
|
#define PyGreenlet_SetParent_NUM 7
|
||||||
|
|
||||||
|
#define PyGreenlet_MAIN_NUM 8
|
||||||
|
#define PyGreenlet_STARTED_NUM 9
|
||||||
|
#define PyGreenlet_ACTIVE_NUM 10
|
||||||
|
#define PyGreenlet_GET_PARENT_NUM 11
|
||||||
|
|
||||||
|
#ifndef GREENLET_MODULE
|
||||||
|
/* This section is used by modules that uses the greenlet C API */
|
||||||
|
static void** _PyGreenlet_API = NULL;
|
||||||
|
|
||||||
|
# define PyGreenlet_Type \
|
||||||
|
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
|
||||||
|
|
||||||
|
# define PyExc_GreenletError \
|
||||||
|
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
|
||||||
|
|
||||||
|
# define PyExc_GreenletExit \
|
||||||
|
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_New(PyObject *args)
|
||||||
|
*
|
||||||
|
* greenlet.greenlet(run, parent=None)
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_New \
|
||||||
|
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_New_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_GetCurrent(void)
|
||||||
|
*
|
||||||
|
* greenlet.getcurrent()
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_GetCurrent \
|
||||||
|
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_Throw(
|
||||||
|
* PyGreenlet *greenlet,
|
||||||
|
* PyObject *typ,
|
||||||
|
* PyObject *val,
|
||||||
|
* PyObject *tb)
|
||||||
|
*
|
||||||
|
* g.throw(...)
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_Throw \
|
||||||
|
(*(PyObject * (*)(PyGreenlet * self, \
|
||||||
|
PyObject * typ, \
|
||||||
|
PyObject * val, \
|
||||||
|
PyObject * tb)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_Throw_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
|
||||||
|
*
|
||||||
|
* g.switch(*args, **kwargs)
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_Switch \
|
||||||
|
(*(PyObject * \
|
||||||
|
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_Switch_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
|
||||||
|
*
|
||||||
|
* g.parent = new_parent
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_SetParent \
|
||||||
|
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* PyGreenlet_GetParent(PyObject* greenlet)
|
||||||
|
*
|
||||||
|
* return greenlet.parent;
|
||||||
|
*
|
||||||
|
* This could return NULL even if there is no exception active.
|
||||||
|
* If it does not return NULL, you are responsible for decrementing the
|
||||||
|
* reference count.
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_GetParent \
|
||||||
|
(*(PyGreenlet* (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* deprecated, undocumented alias.
|
||||||
|
*/
|
||||||
|
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
|
||||||
|
|
||||||
|
# define PyGreenlet_MAIN \
|
||||||
|
(*(int (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
|
||||||
|
|
||||||
|
# define PyGreenlet_STARTED \
|
||||||
|
(*(int (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
|
||||||
|
|
||||||
|
# define PyGreenlet_ACTIVE \
|
||||||
|
(*(int (*)(PyGreenlet*)) \
|
||||||
|
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Macro that imports greenlet and initializes C API */
|
||||||
|
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
|
||||||
|
keep the older definition to be sure older code that might have a copy of
|
||||||
|
the header still works. */
|
||||||
|
# define PyGreenlet_Import() \
|
||||||
|
{ \
|
||||||
|
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* GREENLET_MODULE */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
#endif /* !Py_GREENLETOBJECT_H */
|
||||||
1
venv310/lib64
Symbolic link
1
venv310/lib64
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
lib
|
||||||
3
venv310/pyvenv.cfg
Normal file
3
venv310/pyvenv.cfg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.10.18
|
||||||
35
xl/README.md
35
xl/README.md
@ -1,35 +0,0 @@
|
|||||||
installation :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install git+{reponame}
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
example read :
|
|
||||||
|
|
||||||
|
|
||||||
``` python
|
|
||||||
from xl import XL
|
|
||||||
|
|
||||||
file_path = "./1.xlsx"
|
|
||||||
sheet_name = "Sheet"
|
|
||||||
with XL(file_path) as excel_to_dict:
|
|
||||||
data = excel_to_dict.sheet_to_dict(sheet_name)
|
|
||||||
print(data)
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
example write :
|
|
||||||
|
|
||||||
|
|
||||||
``` python
|
|
||||||
from xl import XL
|
|
||||||
|
|
||||||
file_path = "./1.xlsx"
|
|
||||||
data = [
|
|
||||||
{"Name":"Alice", "Age":30},
|
|
||||||
{"Name":"Bob", "Age":25},
|
|
||||||
{"Name":"Charlie", "Age":35},
|
|
||||||
]
|
|
||||||
XL(file_path).json_to_sheet(data)
|
|
||||||
```
|
|
||||||
@ -1 +0,0 @@
|
|||||||
from .main import XL
|
|
||||||
67
xl/main.py
67
xl/main.py
@ -1,67 +0,0 @@
|
|||||||
import openpyxl
|
|
||||||
|
|
||||||
class XL:
|
|
||||||
def __init__(self, file_path: str):
|
|
||||||
"""
|
|
||||||
Initialize the class with the path to the Excel file.
|
|
||||||
"""
|
|
||||||
self.file_path = file_path
|
|
||||||
self.workbook = None
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
"""
|
|
||||||
Open the workbook when entering the context.
|
|
||||||
"""
|
|
||||||
self.workbook = openpyxl.load_workbook(self.file_path)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
"""
|
|
||||||
Close the workbook when exiting the context.
|
|
||||||
"""
|
|
||||||
if self.workbook:
|
|
||||||
self.workbook.close()
|
|
||||||
|
|
||||||
def sheet_to_dict(self, sheet_name: str):
|
|
||||||
"""
|
|
||||||
Convert a sheet's data to a dictionary, removing rows with None values.
|
|
||||||
"""
|
|
||||||
if not self.workbook:
|
|
||||||
raise ValueError("Workbook is not loaded. Ensure you use 'with' to open the file.")
|
|
||||||
|
|
||||||
if sheet_name not in self.workbook.sheetnames:
|
|
||||||
raise ValueError(f"Sheet '{sheet_name}' not found in the workbook.")
|
|
||||||
|
|
||||||
sheet = self.workbook[sheet_name]
|
|
||||||
headers = [cell.value for cell in sheet[1] if cell.value is not None]
|
|
||||||
|
|
||||||
data_list = []
|
|
||||||
for row in sheet.iter_rows(min_row=2, values_only=True):
|
|
||||||
row_dict = {headers[i]: cell for i, cell in enumerate(row) if i < len(headers)}
|
|
||||||
# Remove keys with None values
|
|
||||||
row_dict = {key: value for key, value in row_dict.items() if value is not None}
|
|
||||||
if row_dict: # Only include non-empty rows
|
|
||||||
data_list.append(row_dict)
|
|
||||||
|
|
||||||
return data_list
|
|
||||||
|
|
||||||
def json_to_sheet(self, data:list[dict]):
|
|
||||||
"""
|
|
||||||
Convert a json to a sheet.
|
|
||||||
"""
|
|
||||||
workbook = openpyxl.Workbook()
|
|
||||||
sheet = workbook.active
|
|
||||||
|
|
||||||
headers = []
|
|
||||||
for item in data:
|
|
||||||
for key in item:
|
|
||||||
if key not in headers:
|
|
||||||
headers.append(key)
|
|
||||||
|
|
||||||
for col_index, header in enumerate(headers):
|
|
||||||
sheet.cell(row=1, column=col_index+1, value=header)
|
|
||||||
|
|
||||||
for row_index, row_data in enumerate(data):
|
|
||||||
for col_index, key in enumerate(headers):
|
|
||||||
sheet.cell(row=row_index+2, column=col_index+1, value=row_data.get(key, ""))
|
|
||||||
workbook.save(self.file_path)
|
|
||||||
Loading…
x
Reference in New Issue
Block a user