Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ output/
### Local demos / scratch notes
demos/

### Local sample data
data/
### Local sample data (top-level only; must not swallow tests/pycharting/data/)
/data/

__marimo__

Expand Down
2 changes: 1 addition & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
INHERIT: docs/mkdocs-base.yml

site_name: pycharting
site_description: Add your description here.
site_description: High-performance financial charting library for OHLC data visualization with technical indicators
site_url: https://alihaskar.github.io/pycharting/
repo_url: https://github.com/alihaskar/pycharting
repo_name: alihaskar/pycharting
Expand Down
58 changes: 50 additions & 8 deletions src/pycharting/api/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,47 @@
import logging
import time
import webbrowser
from typing import Any, cast
from typing import Any, TypedDict, cast

import numpy as np
import pandas as pd

from pycharting.api.routes import _data_managers
from pycharting.core.lifecycle import ChartServer
from pycharting.data.ingestion import DataManager, SubplotSpec
from pycharting.data.ingestion import DataManager, DataValidationError, SubplotSpec

logger = logging.getLogger(__name__)

# Global server instance
_active_server: ChartServer | None = None


class PlotResult(TypedDict, total=False):
"""Structured result returned by :func:`plot`.

``status`` and ``session_id`` are always present. On success the URL/server
fields are populated; on failure ``stage`` ("validation" or "server") and
``error`` describe what went wrong.
"""

status: str
session_id: str
url: str
server_url: str
data_points: int
server_running: bool
stage: str
error: str


class ServerStatus(TypedDict):
"""Structured result returned by :func:`get_server_status`."""

running: bool
server_info: dict[str, Any] | None
active_sessions: int


def _notify(message: str) -> None:
"""Emit a user-facing console message.

Expand Down Expand Up @@ -60,7 +86,7 @@ def plot(
open_browser: bool = True,
server_timeout: float = 2.0,
block: bool = True,
) -> dict[str, Any]:
) -> PlotResult:
"""Generate and display an interactive OHLC (Open-High-Low-Close) or Line chart.

This function is the primary interface for PyCharting. It performs the following steps:
Expand Down Expand Up @@ -227,7 +253,7 @@ def plot(
logger.warning(f"Could not open browser: {e}")
_notify(f"Please open this URL manually: {chart_url}")

result = {
result: PlotResult = {
"status": "success",
"url": chart_url,
"server_url": server_info["url"],
Expand All @@ -245,7 +271,7 @@ def plot(
_notify("")

# Block until server shutdown if requested
if block and _active_server: # pragma: no cover
if block and _active_server:
logger.info("Blocking until chart is closed (press Ctrl+C to force exit)...")
_notify("Keeping server alive until you close the browser page...")
try:
Expand All @@ -259,12 +285,27 @@ def plot(
_notify("\n⚠️ Interrupted - stopping server...")
_active_server.stop_server()

except DataValidationError as e:
# Input data failed validation — the user can fix this by correcting
# their arrays before calling plot() again.
logger.error(f"Invalid input data: {e}", exc_info=True)
_notify(f"\n✗ Invalid input data: {e}\n")

return {
"status": "error",
"stage": "validation",
"error": str(e),
"session_id": session_id,
}
except Exception as e:
# Server startup, browser launch, or other runtime failure — not
# something the caller's data can fix.
logger.error(f"Error creating chart: {e}", exc_info=True)
_notify(f"\n✗ Error creating chart: {e}\n")

return {
"status": "error",
"stage": "server",
"error": str(e),
"session_id": session_id,
}
Expand Down Expand Up @@ -299,7 +340,7 @@ def stop_server() -> None:
_notify("ⓘ No active server to stop")


def get_server_status() -> dict[str, Any]:
def get_server_status() -> ServerStatus:
"""Retrieve the current status of the background chart server.

This is useful for debugging connection issues or checking if a session is still active.
Expand Down Expand Up @@ -339,8 +380,9 @@ def get_server_status() -> dict[str, Any]:
def _repr_html_() -> str:
"""Jupyter notebook representation."""
status = get_server_status()
if status["running"]:
url = f"http://{status['server_info']['host']}:{status['server_info']['port']}"
server_info = status["server_info"]
if status["running"] and server_info is not None:
url = f"http://{server_info['host']}:{server_info['port']}"
return f'''
<div style="padding: 10px; background: #f0f0f0; border-radius: 5px;">
<strong>PyCharting Server</strong><br>
Expand Down
117 changes: 78 additions & 39 deletions src/pycharting/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@
"""

import logging
from typing import Any

from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field

from pycharting.data.ingestion import DataManager

logger = logging.getLogger(__name__)

# Create API router
router = APIRouter(prefix="/api", tags=["data"])

# In-memory storage for active DataManager instances
# In production, this would use a proper session/cache management
_data_managers: dict[str, Any] = {}
_data_managers: dict[str, DataManager] = {}


class DataResponse(BaseModel):
Expand All @@ -50,6 +51,47 @@ class ErrorResponse(BaseModel):
detail: str | None = None


class InitDataResponse(BaseModel):
"""Response model for the demo-data initialization endpoint."""

session_id: str
status: str
data_points: int
message: str


class SessionInfo(BaseModel):
"""Metadata describing a single active data session."""

session_id: str
data_points: int
has_overlays: bool
has_subplots: bool


class SessionListResponse(BaseModel):
"""Response model for the sessions listing endpoint."""

sessions: list[SessionInfo]
count: int


class DeleteSessionResponse(BaseModel):
"""Response model for the session deletion endpoint."""

session_id: str
status: str
message: str


class StatusResponse(BaseModel):
"""Response model for the API status endpoint."""

status: str
active_sessions: int
endpoints: dict[str, str]


@router.get("/data", response_model=DataResponse)
async def get_data(
start_index: int = Query(0, ge=0, description="Start index for data slice"),
Expand Down Expand Up @@ -96,10 +138,10 @@ async def get_data(
raise HTTPException(status_code=500, detail=f"Error fetching data: {e!s}") from e


@router.post("/data/init")
@router.post("/data/init", response_model=InitDataResponse)
async def initialize_data(
session_id: str = Query("default", description="Session identifier"),
):
) -> InitDataResponse:
"""Initialize a demo data session.

This endpoint is primarily used for testing or the standalone demo mode.
Expand All @@ -109,12 +151,10 @@ async def initialize_data(
session_id (str): The ID to assign to the new session.

Returns:
dict: Status message and session details.
InitDataResponse: Status message and session details.
"""
import numpy as np

from pycharting.data.ingestion import DataManager

try:
# Generate demo OHLC data
n = 1000
Expand Down Expand Up @@ -159,38 +199,37 @@ async def initialize_data(
logger.exception("Error initializing data")
raise HTTPException(status_code=500, detail=f"Error initializing data: {e!s}") from e
else:
return {
"session_id": session_id,
"status": "initialized",
"data_points": n,
"message": "Demo dataset created successfully",
}
return InitDataResponse(
session_id=session_id,
status="initialized",
data_points=n,
message="Demo dataset created successfully",
)


@router.get("/sessions")
async def list_sessions():
@router.get("/sessions", response_model=SessionListResponse)
async def list_sessions() -> SessionListResponse:
"""List all currently active data sessions.

Returns:
dict: A dictionary containing a list of session objects, each with metadata
like the number of data points and active features (overlays, subplots).
SessionListResponse: A list of session objects, each with metadata like the
number of data points and active features (overlays, subplots).
"""
sessions = []
for session_id, dm in _data_managers.items():
sessions.append(
{
"session_id": session_id,
"data_points": dm.length,
"has_overlays": len(dm.overlays) > 0,
"has_subplots": len(dm.subplots) > 0,
}
sessions = [
SessionInfo(
session_id=session_id,
data_points=dm.length,
has_overlays=len(dm.overlays) > 0,
has_subplots=len(dm.subplots) > 0,
)
for session_id, dm in _data_managers.items()
]

return {"sessions": sessions, "count": len(sessions)}
return SessionListResponse(sessions=sessions, count=len(sessions))


@router.delete("/sessions/{session_id}")
async def delete_session(session_id: str):
@router.delete("/sessions/{session_id}", response_model=DeleteSessionResponse)
async def delete_session(session_id: str) -> DeleteSessionResponse:
"""Remove a data session from memory.

This frees up resources associated with a specific dataset.
Expand All @@ -199,7 +238,7 @@ async def delete_session(session_id: str):
session_id (str): The ID of the session to remove.

Returns:
dict: Confirmation message.
DeleteSessionResponse: Confirmation message.

Raises:
HTTPException(404): If the session ID is not found.
Expand All @@ -210,23 +249,23 @@ async def delete_session(session_id: str):
del _data_managers[session_id]
logger.info(f"Deleted session '{session_id}'")

return {"session_id": session_id, "status": "deleted", "message": "Session deleted successfully"}
return DeleteSessionResponse(session_id=session_id, status="deleted", message="Session deleted successfully")


@router.get("/status")
async def api_status():
@router.get("/status", response_model=StatusResponse)
async def api_status() -> StatusResponse:
"""Get API status and statistics.

Returns:
API status information
StatusResponse: API status information.
"""
return {
"status": "healthy",
"active_sessions": len(_data_managers),
"endpoints": {
return StatusResponse(
status="healthy",
active_sessions=len(_data_managers),
endpoints={
"data": "/api/data",
"init": "/api/data/init",
"sessions": "/api/sessions",
"status": "/api/status",
},
}
)
8 changes: 4 additions & 4 deletions src/pycharting/core/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def _add_websocket_endpoint(self):
"""Add WebSocket heartbeat endpoint to the app."""

@self.app.websocket("/ws/heartbeat")
async def websocket_heartbeat(websocket: WebSocket): # pragma: no cover
async def websocket_heartbeat(websocket: WebSocket):
"""WebSocket endpoint for connection monitoring."""
await websocket.accept()
self._websocket_connected = True
Expand All @@ -101,7 +101,7 @@ def _monitor_connection(self):
while self._running and not self._shutdown_event.is_set():
time.sleep(1)

if self._websocket_connected and self._last_heartbeat: # pragma: no cover
if self._websocket_connected and self._last_heartbeat:
# Check if heartbeat is stale
elapsed = (datetime.now() - self._last_heartbeat).total_seconds()
if elapsed > self.auto_shutdown_timeout:
Expand All @@ -110,7 +110,7 @@ def _monitor_connection(self):
self.stop_server()
break

elif not self._websocket_connected and self._last_heartbeat: # pragma: no cover
elif not self._websocket_connected and self._last_heartbeat:
# Client disconnected, wait for timeout then shutdown
logger.info(f"Waiting {self.auto_shutdown_timeout}s before auto-shutdown")
time.sleep(self.auto_shutdown_timeout)
Expand All @@ -132,7 +132,7 @@ def _run_server(self):

try:
self._server.run()
except Exception: # pragma: no cover
except Exception:
logger.exception("Server error")
finally:
self._running = False
Expand Down
Loading