diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e5047b --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +TABDEAL_API_KEY=YOUR_API_KEY_HERE +TABDEAL_API_SECRET=YOUR_API_SECRET_HERE +TABDEAL_BASE_URL=https://api1.tabdeal.org +TABDEAL_MARKET=spot +TABDEAL_SYMBOL=BTC_IRT diff --git a/README.md b/README.md index 00af7c2..39f5b00 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,32 @@ Official python package to use [Tabdeal Exchange](https://www.tabdeal.org/) API +## Quickstart Panel + +This fork adds a beginner-friendly desktop quickstart helper for local setup only. + +- credentials are stored locally on the current machine only +- no real trading is performed by the panel or generated example +- the panel only supports SDK install/update, public ping, account test, config save, and example generation + +### Windows + +```powershell +.\install.ps1 +``` + +### Linux + +```bash +chmod +x install.sh +./install.sh +``` + +### Safe local configuration + +- use placeholders from `.env.example` +- generated example files read credentials from environment variables +- example generation refuses to overwrite an existing file automatically ## Installation @@ -88,3 +114,10 @@ There are 2 types of exceptions returned from the library: - `detail` - Detail of exception - `tabdeal.exceptions.ServerException` - This is thrown when server returns `5XX`, it's an issue from server side. + +## Developer checks + +```bash +python -m compileall tabdeal tests +python -m unittest discover -s tests +``` diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..85bd73b --- /dev/null +++ b/install.ps1 @@ -0,0 +1,64 @@ +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$appHome = Join-Path $env:LOCALAPPDATA "TabdealPythonSDK" +$venvPath = Join-Path $appHome "venv" +$venvPython = Join-Path $venvPath "Scripts\python.exe" + +function Resolve-Python { + $candidates = @( + @{ Cmd = "py"; Args = @("-3", "-c", "import sys; print(sys.executable)") }, + @{ Cmd = "python"; Args = @("-c", "import sys; print(sys.executable)") } + ) + + foreach ($candidate in $candidates) { + try { + $resolved = & $candidate.Cmd @($candidate.Args) 2>$null + if ($LASTEXITCODE -eq 0 -and $resolved) { + return $resolved.Trim() + } + } catch { + } + } + + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Host "Python 3 was not found. Attempting installation with winget..." + winget install -e --id Python.Python.3.11 --accept-package-agreements --accept-source-agreements + return Resolve-Python + } + + throw "Python 3 was not found and winget is unavailable. Please install Python 3 manually." +} + +function Test-Tk { + param( + [string]$PythonExecutable + ) + + & $PythonExecutable -c "import tkinter" 2>$null + return $LASTEXITCODE -eq 0 +} + +$python = Resolve-Python + +if (-not (Test-Tk -PythonExecutable $python)) { + Write-Host "Tkinter is not available in the detected Python installation." + Write-Host "Please reinstall Python with Tcl/Tk support enabled, then run this script again." + exit 1 +} + +New-Item -ItemType Directory -Force -Path $appHome | Out-Null + +if (-not (Test-Path $venvPython)) { + Write-Host "Creating virtual environment at $venvPath" + & $python -m venv $venvPath +} + +Write-Host "Upgrading pip..." +& $venvPython -m pip install --upgrade pip + +Write-Host "Installing tabdeal-python from $projectRoot" +& $venvPython -m pip install --upgrade $projectRoot + +Write-Host "Launching Tabdeal Quickstart..." +& $venvPython -m tabdeal diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ee12324 --- /dev/null +++ b/install.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/tabdeal-python-sdk" +VENV_PATH="$APP_HOME/venv" +VENV_PYTHON="$VENV_PATH/bin/python" + +have_cmd() { + command -v "$1" >/dev/null 2>&1 +} + +need_sudo() { + if [[ "${EUID}" -ne 0 ]]; then + echo "sudo" + fi +} + +install_prerequisites() { + local sudo_cmd + sudo_cmd="$(need_sudo)" + + echo "Python 3 or Tk support is missing. Attempting a conservative install..." + + if have_cmd apt-get; then + ${sudo_cmd} apt-get update + ${sudo_cmd} apt-get install -y python3 python3-venv python3-tk + return + fi + if have_cmd dnf; then + ${sudo_cmd} dnf install -y python3 python3-tkinter + return + fi + if have_cmd yum; then + ${sudo_cmd} yum install -y python3 tkinter + return + fi + if have_cmd pacman; then + ${sudo_cmd} pacman -Sy --noconfirm python tk + return + fi + if have_cmd zypper; then + ${sudo_cmd} zypper install -y python3 python3-tk + return + fi + if have_cmd apk; then + ${sudo_cmd} apk add --no-cache python3 py3-pip py3-virtualenv tcl tk + return + fi + + echo "Unsupported package manager. Install python3, python3-venv, and tkinter manually." >&2 + exit 1 +} + +if ! have_cmd python3; then + install_prerequisites +fi + +if ! python3 -c "import tkinter" >/dev/null 2>&1; then + install_prerequisites +fi + +mkdir -p "$APP_HOME" + +if [[ ! -x "$VENV_PYTHON" ]]; then + echo "Creating virtual environment in $VENV_PATH" + python3 -m venv "$VENV_PATH" +fi + +echo "Upgrading pip..." +"$VENV_PYTHON" -m pip install --upgrade pip + +echo "Installing tabdeal-python from $PROJECT_ROOT" +"$VENV_PYTHON" -m pip install --upgrade "$PROJECT_ROOT" + +echo "Launching Tabdeal Quickstart..." +"$VENV_PYTHON" -m tabdeal diff --git a/tabdeal/__main__.py b/tabdeal/__main__.py new file mode 100644 index 0000000..e60c12c --- /dev/null +++ b/tabdeal/__main__.py @@ -0,0 +1,5 @@ +from tabdeal.panel import main + + +if __name__ == "__main__": + main() diff --git a/tabdeal/panel.py b/tabdeal/panel.py new file mode 100644 index 0000000..d8c2134 --- /dev/null +++ b/tabdeal/panel.py @@ -0,0 +1,262 @@ +import json +import os +import subprocess +import threading +import webbrowser + +from tabdeal.quickstart import ( + QuickstartError, + app_home, + example_script_path, + generate_example_script, + install_or_update_sdk, + load_config, + save_config, + test_authenticated_connection, + test_public_connection, +) + + +def load_tk(): + import tkinter as tk + from tkinter import messagebox, ttk + + return tk, ttk, messagebox + + +class TabdealPanel(object): + def __init__(self, root): + self.tk, self.ttk, self.messagebox = load_tk() + self.root = root + self.root.title("Tabdeal Python Quickstart") + self.root.geometry("860x640") + self.root.minsize(760, 560) + + config = load_config() + self.api_key_var = self.tk.StringVar(value=config.get("api_key", "")) + self.api_secret_var = self.tk.StringVar(value=config.get("api_secret", "")) + self.base_url_var = self.tk.StringVar(value=config.get("base_url", "https://api1.tabdeal.org")) + self.market_var = self.tk.StringVar(value=config.get("market", "spot")) + self.symbol_var = self.tk.StringVar(value=config.get("example_symbol", "BTC_IRT")) + self.status_var = self.tk.StringVar(value="Ready") + + self._build_ui() + self.log( + "Panel ready.\n" + "Credentials stay on this computer only.\n" + "This helper does not perform real trading.\n" + "Supported actions: install/update, public ping, account test, config save, example generation.\n" + "Config folder: {config_dir}\n" + "Example path: {example_path}".format( + config_dir=app_home(), + example_path=example_script_path(), + ) + ) + + def _build_ui(self): + ttk = self.ttk + + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(1, weight=1) + + header = ttk.Frame(self.root, padding=16) + header.grid(row=0, column=0, sticky="ew") + header.columnconfigure(0, weight=1) + + ttk.Label( + header, + text="Tabdeal Python Quickstart", + font=("Segoe UI", 18, "bold"), + ).grid(row=0, column=0, sticky="w") + ttk.Label( + header, + text="Beginner-friendly installer and connection check for the SDK.", + ).grid(row=1, column=0, sticky="w", pady=(6, 0)) + + body = ttk.Panedwindow(self.root, orient="horizontal") + body.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 16)) + + left = ttk.Frame(body, padding=12) + right = ttk.Frame(body, padding=12) + body.add(left, weight=2) + body.add(right, weight=3) + + left.columnconfigure(1, weight=1) + right.columnconfigure(0, weight=1) + right.rowconfigure(1, weight=1) + + ttk.Label(left, text="Safety Notice", font=("Segoe UI", 11, "bold")).grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 8) + ) + ttk.Label( + left, + text="Stores credentials locally only. No real trading is performed here.", + foreground="#9c5a00", + wraplength=320, + ).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 14)) + + ttk.Label(left, text="Market").grid(row=2, column=0, sticky="w", pady=6) + market_box = ttk.Combobox( + left, + textvariable=self.market_var, + values=("spot", "future"), + state="readonly", + ) + market_box.grid(row=2, column=1, sticky="ew", pady=6) + + ttk.Label(left, text="Base URL").grid(row=3, column=0, sticky="w", pady=6) + ttk.Entry(left, textvariable=self.base_url_var).grid(row=3, column=1, sticky="ew", pady=6) + + ttk.Label(left, text="API Key").grid(row=4, column=0, sticky="w", pady=6) + ttk.Entry(left, textvariable=self.api_key_var).grid(row=4, column=1, sticky="ew", pady=6) + + ttk.Label(left, text="API Secret").grid(row=5, column=0, sticky="w", pady=6) + ttk.Entry(left, textvariable=self.api_secret_var, show="*").grid( + row=5, column=1, sticky="ew", pady=6 + ) + + ttk.Label(left, text="Example Symbol").grid(row=6, column=0, sticky="w", pady=6) + ttk.Entry(left, textvariable=self.symbol_var).grid(row=6, column=1, sticky="ew", pady=6) + + actions = ttk.LabelFrame(left, text="Actions", padding=12) + actions.grid(row=7, column=0, columnspan=2, sticky="ew", pady=(16, 10)) + actions.columnconfigure(0, weight=1) + actions.columnconfigure(1, weight=1) + + ttk.Button(actions, text="Install / Update SDK", command=self.install_sdk).grid( + row=0, column=0, sticky="ew", padx=(0, 8), pady=6 + ) + ttk.Button(actions, text="Save Settings", command=self.save_settings).grid( + row=0, column=1, sticky="ew", pady=6 + ) + ttk.Button(actions, text="Test Public Ping", command=self.test_ping).grid( + row=1, column=0, sticky="ew", padx=(0, 8), pady=6 + ) + ttk.Button(actions, text="Test Account", command=self.test_account).grid( + row=1, column=1, sticky="ew", pady=6 + ) + ttk.Button(actions, text="Generate Example", command=self.create_example).grid( + row=2, column=0, sticky="ew", padx=(0, 8), pady=6 + ) + ttk.Button(actions, text="Open Config Folder", command=self.open_config_folder).grid( + row=2, column=1, sticky="ew", pady=6 + ) + + ttk.Button( + left, + text="Open Tabdeal Docs", + command=lambda: webbrowser.open("https://docs.tabdeal.org"), + ).grid(row=8, column=0, columnspan=2, sticky="ew", pady=(4, 12)) + + ttk.Label(left, text="Status").grid(row=9, column=0, sticky="w") + ttk.Label(left, textvariable=self.status_var).grid(row=9, column=1, sticky="w") + + ttk.Label(right, text="Activity Log", font=("Segoe UI", 12, "bold")).grid( + row=0, column=0, sticky="w", pady=(0, 8) + ) + self.log_widget = self.tk.Text(right, wrap="word", height=26) + self.log_widget.grid(row=1, column=0, sticky="nsew") + scrollbar = ttk.Scrollbar(right, orient="vertical", command=self.log_widget.yview) + scrollbar.grid(row=1, column=1, sticky="ns") + self.log_widget.configure(yscrollcommand=scrollbar.set) + + def current_config(self): + return { + "api_key": self.api_key_var.get().strip(), + "api_secret": self.api_secret_var.get().strip(), + "base_url": self.base_url_var.get().strip(), + "market": self.market_var.get().strip(), + "example_symbol": self.symbol_var.get().strip(), + } + + def log(self, message): + self.log_widget.insert(self.tk.END, message + "\n") + self.log_widget.see(self.tk.END) + + def set_status(self, value): + self.status_var.set(value) + self.root.update_idletasks() + + def _queue_status(self, value): + self.root.after(0, lambda: self.set_status(value)) + + def _queue_log(self, message): + self.root.after(0, lambda: self.log(message)) + + def _queue_error(self, message): + self.root.after(0, lambda: self.messagebox.showerror("Tabdeal Quickstart", message)) + + def run_async(self, label, target): + def runner(): + self._queue_status(label) + try: + target() + self._queue_status("Ready") + except Exception as exc: # pragma: no cover + self._queue_log("[ERROR] {0}".format(exc)) + self._queue_status("Error") + self._queue_error(str(exc)) + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + + def save_settings(self): + path = save_config(self.current_config(), overwrite=True) + self.log("Saved settings to {0}".format(path)) + self.set_status("Saved") + + def install_sdk(self): + def work(): + result = install_or_update_sdk() + self._queue_log("$ " + " ".join(result.command)) + self._queue_log(result.output or "(no output)") + if result.returncode != 0: + raise QuickstartError("SDK installation failed. See the activity log for details.") + self._queue_log("SDK install/update completed successfully.") + + self.run_async("Installing", work) + + def test_ping(self): + def work(): + self.save_settings() + result = test_public_connection(self.current_config()) + self._queue_log("Public ping response:") + self._queue_log(json.dumps(result, indent=2, ensure_ascii=False)) + + self.run_async("Testing public ping", work) + + def test_account(self): + def work(): + self.save_settings() + result = test_authenticated_connection(self.current_config()) + self._queue_log("Authenticated account response:") + self._queue_log(json.dumps(result, indent=2, ensure_ascii=False)) + + self.run_async("Testing account", work) + + def create_example(self): + path = generate_example_script(self.current_config(), overwrite=False) + self.log("Generated example at {0}".format(path)) + self.set_status("Example generated") + + def open_config_folder(self): + folder = app_home() + folder.mkdir(parents=True, exist_ok=True) + if os.name == "nt": + os.startfile(str(folder)) # type: ignore[attr-defined] + else: + subprocess.run(["xdg-open", str(folder)], check=False) + self.log("Opened config folder: {0}".format(folder)) + + +def main(): + tk, ttk, _ = load_tk() + root = tk.Tk() + try: + style = ttk.Style(root) + if "clam" in style.theme_names(): + style.theme_use("clam") + except Exception: + pass + TabdealPanel(root) + root.mainloop() diff --git a/tabdeal/quickstart.py b/tabdeal/quickstart.py new file mode 100644 index 0000000..0419864 --- /dev/null +++ b/tabdeal/quickstart.py @@ -0,0 +1,197 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +from tabdeal.exceptions import CoreException +from tabdeal.future import Future +from tabdeal.spot import Spot + + +APP_DIR_NAME = "TabdealPythonSDK" +CONFIG_FILE_NAME = "config.json" +EXAMPLE_FILE_NAME = "example_client.py" +DEFAULT_BASE_URL = "https://api1.tabdeal.org" +DEFAULT_MARKET = "spot" +DEFAULT_SYMBOL = "BTC_IRT" + + +class QuickstartError(CoreException): + pass + + +class InstallResult(object): + def __init__(self, command, returncode, output): + self.command = command + self.returncode = returncode + self.output = output + + +def app_home(): + if os.name == "nt": + root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + return root / APP_DIR_NAME + return Path.home() / ".local" / "share" / "tabdeal-python-sdk" + + +def config_path(): + return app_home() / CONFIG_FILE_NAME + + +def example_script_path(): + return app_home() / EXAMPLE_FILE_NAME + + +def ensure_app_home(): + path = app_home() + path.mkdir(parents=True, exist_ok=True) + return path + + +def default_config(): + return { + "api_key": "", + "api_secret": "", + "base_url": DEFAULT_BASE_URL, + "market": DEFAULT_MARKET, + "example_symbol": DEFAULT_SYMBOL, + } + + +def load_config(): + path = config_path() + config = default_config() + if not path.exists(): + return config + + with path.open("r", encoding="utf-8") as handle: + config.update(json.load(handle)) + + return config + + +def save_config(config, overwrite=False): + ensure_app_home() + path = config_path() + if path.exists() and not overwrite: + raise QuickstartError("Config already exists. Refusing to overwrite without explicit permission.") + + merged = default_config() + merged.update(config) + + with path.open("w", encoding="utf-8") as handle: + json.dump(merged, handle, indent=2, sort_keys=True) + + return path + + +def project_root(): + return Path(__file__).resolve().parent.parent + + +def install_target(): + root = project_root() + if (root / "setup.py").exists(): + return str(root) + return "tabdeal-python" + + +def run_command(command): + completed = subprocess.run( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + check=False, + ) + return InstallResult(command, completed.returncode, completed.stdout.strip()) + + +def install_or_update_sdk(python_executable=None): + python_executable = python_executable or sys.executable + return run_command( + [ + python_executable, + "-m", + "pip", + "install", + "--upgrade", + install_target(), + ] + ) + + +def build_client(config): + normalized = default_config() + normalized.update(config or {}) + market = str(normalized.get("market") or DEFAULT_MARKET).lower() + api_key = normalized.get("api_key") or None + api_secret = normalized.get("api_secret") or None + base_url = normalized.get("base_url") or DEFAULT_BASE_URL + + if market == "future": + return Future(api_key=api_key, api_secret=api_secret, base_url=base_url) + + return Spot(api_key=api_key, api_secret=api_secret, base_url=base_url) + + +def test_public_connection(config): + client = build_client(config) + return client.ping() + + +def test_authenticated_connection(config): + normalized = default_config() + normalized.update(config or {}) + if not normalized.get("api_key") or not normalized.get("api_secret"): + raise QuickstartError("API key and API secret are required for the account test.") + + client = build_client(normalized) + return client.account() + + +def generate_example_script(config, overwrite=False): + ensure_app_home() + path = example_script_path() + if path.exists() and not overwrite: + raise QuickstartError("Example script already exists. Refusing to overwrite it automatically.") + + normalized = default_config() + normalized.update(config or {}) + market = str(normalized.get("market") or DEFAULT_MARKET).lower() + class_name = "Future" if market == "future" else "Spot" + module_name = "future" if market == "future" else "spot" + symbol = normalized.get("example_symbol") or DEFAULT_SYMBOL + base_url = normalized.get("base_url") or DEFAULT_BASE_URL + + contents = """import os + +from tabdeal.{module_name} import {class_name} + + +API_KEY = os.getenv("TABDEAL_API_KEY", "YOUR_API_KEY_HERE") +API_SECRET = os.getenv("TABDEAL_API_SECRET", "YOUR_API_SECRET_HERE") +BASE_URL = os.getenv("TABDEAL_BASE_URL", "{base_url}") + + +def main(): + # Quickstart helper only. No real trading is performed by this example. + client = {class_name}(api_key=API_KEY, api_secret=API_SECRET, base_url=BASE_URL) + print("Ping:", client.ping()) + print("Exchange info:", client.exchange_info(symbol="{symbol}")) + + +if __name__ == "__main__": + main() +""".format( + module_name=module_name, + class_name=class_name, + base_url=base_url, + symbol=symbol, + ) + + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(contents) + + return path diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py new file mode 100644 index 0000000..5cb45c5 --- /dev/null +++ b/tests/test_quickstart.py @@ -0,0 +1,76 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from tabdeal.quickstart import ( + DEFAULT_BASE_URL, + QuickstartError, + generate_example_script, + load_config, + save_config, + test_authenticated_connection, +) + + +class QuickstartTests(unittest.TestCase): + def test_load_config_returns_defaults_when_missing(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + with patch("tabdeal.quickstart.app_home", return_value=app_dir): + config = load_config() + + self.assertEqual(config["base_url"], DEFAULT_BASE_URL) + self.assertEqual(config["market"], "spot") + self.assertEqual(config["example_symbol"], "BTC_IRT") + + def test_save_config_creates_local_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + with patch("tabdeal.quickstart.app_home", return_value=app_dir): + path = save_config({"market": "future", "example_symbol": "BTCUSDT"}) + loaded = load_config() + saved_exists = path.exists() + + self.assertTrue(saved_exists) + self.assertEqual(loaded["market"], "future") + self.assertEqual(loaded["example_symbol"], "BTCUSDT") + self.assertEqual(loaded["api_key"], "") + self.assertEqual(loaded["api_secret"], "") + + def test_generate_example_script_uses_placeholders_not_real_secrets(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + with patch("tabdeal.quickstart.app_home", return_value=app_dir): + path = generate_example_script( + { + "api_key": "REAL_KEY_123", + "api_secret": "REAL_SECRET_456", + "market": "future", + "example_symbol": "BTCUSDT", + } + ) + content = path.read_text(encoding="utf-8") + + self.assertIn('os.getenv("TABDEAL_API_KEY", "YOUR_API_KEY_HERE")', content) + self.assertIn('os.getenv("TABDEAL_API_SECRET", "YOUR_API_SECRET_HERE")', content) + self.assertIn("from tabdeal.future import Future", content) + self.assertIn('symbol="BTCUSDT"', content) + self.assertNotIn("REAL_KEY_123", content) + self.assertNotIn("REAL_SECRET_456", content) + + def test_generate_example_script_refuses_to_overwrite_existing_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + app_dir = Path(tmpdir) + with patch("tabdeal.quickstart.app_home", return_value=app_dir): + generate_example_script({}) + with self.assertRaises(QuickstartError): + generate_example_script({}) + + def test_account_test_requires_keys_without_network(self): + with self.assertRaises(QuickstartError): + test_authenticated_connection({}) + + +if __name__ == "__main__": + unittest.main()