feat: versioning, feat: in app configueration, feat: single exe, feat: reasoning, action: inital version, fix: config saving
Build Release EXE / build-windows-exe (release) Successful in 1m5s

This commit is contained in:
2026-05-06 15:06:15 -04:00
parent da016c23cb
commit 11adcc160a
19 changed files with 1926 additions and 24 deletions
+126 -4
View File
@@ -1,11 +1,64 @@
from functools import lru_cache
from __future__ import annotations
from pydantic import Field
from functools import lru_cache
import os
from pathlib import Path
import sys
from typing import Any
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
CONFIG_FIELDS: dict[str, dict[str, Any]] = {
"ollama_base_url": {"env": "OLLAMA_BASE_URL", "type": "string", "secret": False},
"ollama_model": {"env": "OLLAMA_MODEL", "type": "string", "secret": False},
"ollama_num_ctx": {"env": "OLLAMA_NUM_CTX", "type": "integer", "secret": False},
"uex_base_url": {"env": "UEX_BASE_URL", "type": "string", "secret": False},
"uex_secret_key": {"env": "UEX_SECRET_KEY", "type": "string", "secret": True},
"uex_bearer_token": {"env": "UEX_BEARER_TOKEN", "type": "string", "secret": True},
"traderai_user_name": {"env": "TRADERAI_USER_NAME", "type": "string", "secret": False},
"traderai_memory_path": {"env": "TRADERAI_MEMORY_PATH", "type": "string", "secret": False},
"uex_notification_poll_seconds": {"env": "UEX_NOTIFICATION_POLL_SECONDS", "type": "integer", "secret": False},
"require_write_approval": {"env": "REQUIRE_WRITE_APPROVAL", "type": "boolean", "secret": False},
}
def app_data_dir() -> Path:
if sys.platform == "win32":
root = os.environ.get("LOCALAPPDATA")
if root:
return Path(root) / "TraderAI"
return Path.home() / ".traderai"
def ensure_app_data_dir() -> Path:
path = app_data_dir()
path.mkdir(parents=True, exist_ok=True)
return path
def user_config_path() -> Path:
return ensure_app_data_dir() / ".env"
def default_memory_path() -> Path:
return ensure_app_data_dir() / "traderai.sqlite3"
def log_path() -> Path:
return ensure_app_data_dir() / "TraderAI.log"
def edge_profile_dir() -> Path:
return ensure_app_data_dir() / "EdgeProfile"
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
model_config = SettingsConfigDict(
env_file=(".env", str(user_config_path())),
env_file_encoding="utf-8",
)
ollama_base_url: str = "http://localhost:11434"
ollama_model: str = "qwen3.5:9b"
@@ -14,11 +67,80 @@ class Settings(BaseSettings):
uex_secret_key: str | None = Field(default=None)
uex_bearer_token: str | None = Field(default=None)
traderai_user_name: str | None = Field(default=None)
traderai_memory_path: str = "data/traderai.sqlite3"
traderai_memory_path: str = Field(default_factory=lambda: str(default_memory_path()))
uex_notification_poll_seconds: int = 60
require_write_approval: bool = True
@field_validator("uex_secret_key", "uex_bearer_token", "traderai_user_name", mode="before")
@classmethod
def _blank_optional(cls, value: Any) -> Any:
return None if value == "" else value
@field_validator("traderai_memory_path", mode="before")
@classmethod
def _blank_memory_path(cls, value: Any) -> Any:
return str(default_memory_path()) if value == "" or value is None else value
@lru_cache
def get_settings() -> Settings:
return Settings()
def settings_payload(settings: Settings | None = None) -> dict[str, Any]:
current = settings or get_settings()
values = current.model_dump()
return {
"app_data_dir": str(ensure_app_data_dir()),
"config_path": str(user_config_path()),
"log_path": str(log_path()),
"edge_profile_dir": str(edge_profile_dir()),
"values": values,
"fields": CONFIG_FIELDS,
}
def save_settings(values: dict[str, Any]) -> dict[str, Any]:
current = get_settings().model_dump()
next_values = dict(current)
for key, value in values.items():
if key not in CONFIG_FIELDS:
continue
next_values[key] = _coerce_value(key, value)
path = user_config_path()
lines = [
"# TraderAI desktop configuration",
"# Saved by the app. Environment variables still override these values.",
"",
]
for key, meta in CONFIG_FIELDS.items():
value = next_values.get(key)
lines.append(f"{meta['env']}={_env_value(value)}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
get_settings.cache_clear()
return settings_payload(get_settings())
def _coerce_value(key: str, value: Any) -> Any:
field_type = CONFIG_FIELDS[key]["type"]
if value == "":
return None if key in {"uex_secret_key", "uex_bearer_token", "traderai_user_name"} else ""
if field_type == "integer":
return int(value)
if field_type == "boolean":
if isinstance(value, bool):
return value
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
return str(value)
def _env_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
text = str(value)
if not text or any(char.isspace() for char in text) or "#" in text:
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
return text
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
import os
from pathlib import Path
import shutil
import socket
import subprocess
import sys
import threading
import time
import traceback
from typing import NoReturn
import httpx
import uvicorn
from traderai.config import edge_profile_dir, log_path
def resource_path(*parts: str) -> Path:
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
return base.joinpath(*parts)
def main() -> None:
try:
_chdir_to_app_dir()
_log("TraderAI desktop starting")
_log(f"cwd={Path.cwd()}")
_log(f"executable={sys.executable}")
_log(f"frozen={getattr(sys, 'frozen', False)} meipass={getattr(sys, '_MEIPASS', '')}")
port = _select_port()
url = f"http://127.0.0.1:{port}"
_log(f"selected_url={url}")
if _existing_server_ready(url):
_log("existing TraderAI backend found; opening window")
_open_window(url)
return
server_thread = threading.Thread(target=_run_server, args=(port,), daemon=True)
server_thread.start()
_log("backend thread started")
_wait_for_server(url)
_log("backend health check passed")
_open_window(url)
_log("webview closed")
except Exception:
_log("fatal startup error")
_log(traceback.format_exc())
raise
def _chdir_to_app_dir() -> None:
if getattr(sys, "frozen", False):
os.chdir(Path(sys.executable).resolve().parent)
def _select_port() -> int:
preferred = int(os.getenv("TRADERAI_PORT", "8765"))
if _port_available(preferred):
return preferred
_log(f"preferred port {preferred} is in use")
return _free_port()
def _port_available(port: int) -> bool:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", port))
return True
except OSError:
return False
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def _existing_server_ready(url: str) -> bool:
try:
response = httpx.get(f"{url}/api/health", timeout=1)
return response.status_code < 500 and response.headers.get("content-type", "").startswith("application/json")
except httpx.HTTPError:
return False
def _run_server(port: int) -> NoReturn:
try:
_log(f"backend starting on port {port}")
from traderai.server import app
config = uvicorn.Config(
app,
host="127.0.0.1",
port=port,
log_level="info",
log_config=None,
lifespan="on",
)
server = uvicorn.Server(config)
server.run()
_log("backend server stopped")
raise SystemExit(0)
except BaseException:
_log("backend thread crashed")
_log(traceback.format_exc())
raise
def _wait_for_server(url: str) -> None:
deadline = time.monotonic() + 30
last_error = ""
while time.monotonic() < deadline:
try:
response = httpx.get(f"{url}/api/health", timeout=1)
_log(f"health probe status={response.status_code}")
if response.status_code < 500:
return
except httpx.HTTPError as exc:
last_error = str(exc)
_log(f"health probe failed: {last_error}")
time.sleep(0.25)
raise RuntimeError(f"TraderAI backend did not start within 30 seconds. {last_error}")
def _open_window(url: str) -> None:
mode = os.getenv("TRADERAI_DESKTOP_UI", "edge").casefold()
_log(f"ui_mode={mode}")
if mode == "webview":
_open_webview(url)
return
if _open_edge_app(url):
return
_open_browser(url)
def _open_webview(url: str) -> None:
_log("importing pywebview")
import webview
_log("creating pywebview window")
webview.create_window(
"TraderAI",
url,
width=1320,
height=860,
min_size=(980, 680),
text_select=True,
icon=str(resource_path("web", "art", "LBC_Logo.ico")),
)
_log("starting pywebview")
webview.start(gui="edgechromium", debug=False)
def _open_edge_app(url: str) -> bool:
edge = _edge_path()
if not edge:
_log("msedge not found; falling back to default browser")
return False
profile_dir = edge_profile_dir()
profile_dir.mkdir(parents=True, exist_ok=True)
command = [
str(edge),
f"--app={url}",
f"--user-data-dir={profile_dir}",
"--new-window",
"--no-first-run",
"--disable-features=Translate",
f"--app-icon={resource_path('web', 'art', 'LBC_Logo.ico')}",
]
_log(f"launching edge app: {' '.join(command)}")
process = subprocess.Popen(command)
_log(f"edge process id={process.pid}")
time.sleep(2)
if process.poll() is None:
process.wait()
_log("edge app process exited")
return True
_log(f"edge app process exited early code={process.returncode}; keeping backend alive")
_keep_alive()
return True
def _open_browser(url: str) -> None:
import webbrowser
_log(f"opening default browser at {url}")
webbrowser.open(url)
_keep_alive()
def _keep_alive() -> None:
_log("backend staying alive; close TraderAI from Task Manager if no app window owns this process")
while True:
time.sleep(60)
def _edge_path() -> Path | None:
edge = shutil.which("msedge")
if edge:
return Path(edge)
candidates = [
Path(os.environ.get("ProgramFiles", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
Path(os.environ.get("LocalAppData", "")) / "Microsoft" / "Edge" / "Application" / "msedge.exe",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def _log(message: str) -> None:
try:
log_path = _log_path()
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
with log_path.open("a", encoding="utf-8") as file:
file.write(f"[{timestamp}] {message}\n")
except Exception:
pass
def _log_path() -> Path:
return log_path()
if __name__ == "__main__":
main()
+406 -2
View File
@@ -1,8 +1,17 @@
from __future__ import annotations
from pathlib import Path
import os
import json
import shutil
import subprocess
import sys
import threading
import time
import webbrowser
from pathlib import Path
from typing import Any
import httpx
from fastapi import FastAPI
from fastapi import HTTPException
from fastapi.responses import FileResponse, StreamingResponse
@@ -10,11 +19,18 @@ from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from traderai.agent import OllamaAgent, OllamaUnavailable
from traderai.config import save_settings, settings_payload
from traderai.config import get_settings
from traderai.memory import MemoryStore
from traderai.scheduler import WakeScheduler
from traderai.tools import ToolRegistry
from traderai.uex_client import UEXClient
from traderai.version import RELEASES_API_URL, RELEASES_URL, __version__
def resource_path(*parts: str) -> Path:
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent))
return base.joinpath(*parts)
class ChatRequest(BaseModel):
@@ -29,6 +45,18 @@ class ClearMemoryRequest(BaseModel):
include_outbox: bool = True
class ConfigUpdateRequest(BaseModel):
values: dict
class OllamaModelRequest(BaseModel):
model: str | None = None
OLLAMA_DOWNLOAD_URL = "https://ollama.com/download/windows"
UPDATE_ASSET_NAME = "TraderAI.exe"
def create_app() -> FastAPI:
settings = get_settings()
memory = MemoryStore(settings.traderai_memory_path)
@@ -47,7 +75,7 @@ def create_app() -> FastAPI:
scheduler.bind_uex_notifications(uex, settings.uex_notification_poll_seconds)
app = FastAPI(title="TraderAI")
static_dir = Path(__file__).resolve().parent.parent / "web"
static_dir = resource_path("web")
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.on_event("startup")
@@ -93,8 +121,128 @@ def create_app() -> FastAPI:
"ollama": await agent.health(),
"user": memory.get_profile(),
"jobs": scheduler.list_jobs(),
"app_data_dir": settings_payload()["app_data_dir"],
"version": __version__,
}
@app.get("/api/config")
async def inspect_config() -> dict:
return settings_payload()
@app.post("/api/config")
async def update_config(request: ConfigUpdateRequest) -> dict:
updated = save_settings(request.values)
updated["restart_required"] = True
updated["message"] = "Configuration saved. Restart TraderAI for all settings to take effect."
return updated
@app.get("/api/ollama/status")
async def ollama_status() -> dict:
return await inspect_ollama()
@app.post("/api/ollama/launch")
async def launch_ollama() -> dict:
command = ollama_launch_command()
if not command:
raise HTTPException(status_code=404, detail="Ollama is not installed or was not found on PATH.")
try:
popen_hidden(command)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not launch Ollama: {exc}") from exc
status = await inspect_ollama()
status["message"] = "Ollama launch requested."
return status
@app.post("/api/ollama/pull")
async def pull_ollama_model(request: OllamaModelRequest) -> dict:
settings_now = get_settings()
model = (request.model or settings_now.ollama_model).strip()
if not model:
raise HTTPException(status_code=400, detail="No Ollama model is configured.")
cli = find_ollama_cli()
if not cli:
raise HTTPException(status_code=404, detail="Ollama CLI was not found.")
try:
popen_hidden([str(cli), "pull", model])
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not start model install: {exc}") from exc
status = await inspect_ollama()
status["message"] = f"Started installing model {model}."
return status
@app.post("/api/ollama/install")
async def install_ollama() -> dict:
winget = shutil.which("winget")
if not winget:
return {
"started": False,
"message": "winget is not available on this system. Open the download page instead.",
"download_url": OLLAMA_DOWNLOAD_URL,
}
try:
popen_hidden(
[
winget,
"install",
"-e",
"--id",
"Ollama.Ollama",
"--accept-package-agreements",
"--accept-source-agreements",
]
)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Could not start Ollama install: {exc}") from exc
return {"started": True, "message": "Started Ollama install with winget.", "download_url": OLLAMA_DOWNLOAD_URL}
@app.post("/api/ollama/download")
async def download_ollama() -> dict:
webbrowser.open(OLLAMA_DOWNLOAD_URL)
return {"opened": True, "download_url": OLLAMA_DOWNLOAD_URL, "message": "Opened the Ollama download page."}
@app.get("/api/update/check")
async def check_update() -> dict:
return await inspect_update()
@app.post("/api/update/install")
async def install_update() -> dict:
update = await inspect_update()
if not update["available"]:
return {**update, "message": "TraderAI is already up to date."}
if not getattr(sys, "frozen", False):
return {
**update,
"started": False,
"message": "Update download is available, but self-update only runs from the packaged exe.",
}
asset_url = update.get("asset_download_url")
if not asset_url:
raise HTTPException(status_code=404, detail="The latest release does not include TraderAI.exe.")
downloaded = await download_update_asset(asset_url, update["latest_version"])
script = write_update_script(downloaded, Path(sys.executable))
updater_command = [
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
"-ProcessId",
str(os.getpid()),
"-Source",
str(downloaded),
"-Target",
str(Path(sys.executable)),
]
updater_kwargs: dict[str, Any] = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
if sys.platform == "win32":
updater_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
subprocess.Popen(updater_command, **updater_kwargs)
threading.Thread(target=exit_after_update_response, daemon=True).start()
return {**update, "started": True, "message": "Update downloaded. TraderAI will restart into the new version."}
@app.post("/api/chat")
async def chat(request: ChatRequest) -> dict:
try:
@@ -152,4 +300,260 @@ def create_app() -> FastAPI:
return app
async def inspect_ollama() -> dict[str, Any]:
settings = get_settings()
executable = find_ollama_executable()
cli = find_ollama_cli()
models: list[str] = []
online = False
detail = ""
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"{settings.ollama_base_url.rstrip('/')}/api/tags")
response.raise_for_status()
body = response.json()
online = True
models = [item.get("name") or item.get("model") for item in body.get("models", [])]
models = [model for model in models if model]
except (httpx.HTTPError, ValueError) as exc:
detail = str(exc)
installed = bool(executable or cli)
model_available = settings.ollama_model in models
return {
"installed": installed,
"running": online,
"online": online,
"model_available": model_available,
"configured_model": settings.ollama_model,
"base_url": settings.ollama_base_url,
"num_ctx": settings.ollama_num_ctx,
"models": models,
"executable": str(executable) if executable else None,
"cli": str(cli) if cli else None,
"can_auto_install": bool(shutil.which("winget")),
"download_url": OLLAMA_DOWNLOAD_URL,
"message": ollama_status_message(installed, online, model_available, settings.ollama_model),
"detail": detail,
}
def ollama_status_message(installed: bool, running: bool, model_available: bool, model: str) -> str:
if not installed:
return "Ollama is not installed."
if not running:
return "Ollama is installed but not running."
if not model_available:
return f'Ollama is running, but model "{model}" is not installed.'
return "Ollama is ready."
def find_ollama_executable() -> Path | None:
candidates = [
shutil.which("ollama"),
os.environ.get("OLLAMA_EXE"),
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "Ollama.exe"),
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "ollama.exe"),
os.path.join(os.environ.get("ProgramFiles", ""), "Ollama", "Ollama.exe"),
os.path.join(os.environ.get("ProgramFiles", ""), "Ollama", "ollama.exe"),
os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Ollama", "Ollama.exe"),
os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Ollama", "ollama.exe"),
]
for candidate in candidates:
if not candidate:
continue
path = Path(candidate)
if path.exists():
return path
return None
def find_ollama_cli() -> Path | None:
candidates = [
shutil.which("ollama"),
os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "ollama.exe"),
os.path.join(os.environ.get("ProgramFiles", ""), "Ollama", "ollama.exe"),
os.path.join(os.environ.get("ProgramFiles(x86)", ""), "Ollama", "ollama.exe"),
]
for candidate in candidates:
if not candidate:
continue
path = Path(candidate)
if path.exists():
return path
return None
def ollama_launch_command() -> list[str] | None:
executable = find_ollama_executable()
if not executable:
return None
if executable.name == "Ollama.exe":
return [str(executable)]
return [str(executable), "serve"]
def popen_hidden(command: list[str]) -> subprocess.Popen:
kwargs: dict[str, Any] = {"stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
if sys.platform == "win32":
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
return subprocess.Popen(command, **kwargs)
async def inspect_update() -> dict[str, Any]:
try:
latest = await latest_release()
except (httpx.HTTPError, ValueError) as exc:
return {
"current_version": __version__,
"latest_version": None,
"available": False,
"release_url": RELEASES_URL,
"message": f"Could not check releases: {exc}",
}
if not latest:
return {
"current_version": __version__,
"latest_version": None,
"available": False,
"release_url": RELEASES_URL,
"message": "No releases were found.",
}
latest_version = normalize_version(latest.get("tag_name") or latest.get("name") or "")
asset = release_asset(latest, UPDATE_ASSET_NAME)
available = latest_version is not None and compare_versions(latest_version, __version__) > 0
return {
"current_version": __version__,
"latest_version": latest_version,
"available": available,
"release_name": latest.get("name") or latest.get("tag_name"),
"release_url": latest.get("html_url") or RELEASES_URL,
"asset_name": asset.get("name") if asset else None,
"asset_download_url": asset.get("browser_download_url") if asset else None,
"packaged": bool(getattr(sys, "frozen", False)),
"message": update_message(available, latest_version, bool(asset)),
}
async def latest_release() -> dict[str, Any] | None:
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
response = await client.get(RELEASES_API_URL)
response.raise_for_status()
releases = response.json()
if not isinstance(releases, list):
return None
candidates = [
release
for release in releases
if isinstance(release, dict) and not release.get("draft") and not release.get("prerelease")
]
if not candidates:
candidates = [release for release in releases if isinstance(release, dict) and not release.get("draft")]
if not candidates:
return None
return max(candidates, key=lambda release: version_parts(str(release.get("tag_name") or release.get("name") or "0")))
def release_asset(release: dict[str, Any], name: str) -> dict[str, Any] | None:
assets = release.get("assets") or []
if not isinstance(assets, list):
return None
for asset in assets:
if isinstance(asset, dict) and str(asset.get("name", "")).casefold() == name.casefold():
return asset
for asset in assets:
download_url = str(asset.get("browser_download_url", "")) if isinstance(asset, dict) else ""
if download_url.casefold().endswith(f"/{name.casefold()}"):
return asset
return None
async def download_update_asset(url: str, version: str) -> Path:
updates_dir = Path(settings_payload()["app_data_dir"]) / "updates"
updates_dir.mkdir(parents=True, exist_ok=True)
path = updates_dir / f"TraderAI-{version}.exe"
async with httpx.AsyncClient(timeout=120, follow_redirects=True) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with path.open("wb") as file:
async for chunk in response.aiter_bytes():
file.write(chunk)
return path
def write_update_script(source: Path, target: Path) -> Path:
updates_dir = Path(settings_payload()["app_data_dir"]) / "updates"
updates_dir.mkdir(parents=True, exist_ok=True)
script = updates_dir / "apply-update.ps1"
script.write_text(
"\n".join(
[
"param(",
" [Parameter(Mandatory=$true)][int]$ProcessId,",
" [Parameter(Mandatory=$true)][string]$Source,",
" [Parameter(Mandatory=$true)][string]$Target",
")",
"$ErrorActionPreference = 'Stop'",
"try { Wait-Process -Id $ProcessId -Timeout 60 -ErrorAction SilentlyContinue } catch {}",
"Start-Sleep -Seconds 1",
"Copy-Item -LiteralPath $Source -Destination $Target -Force",
"Start-Process -FilePath $Target",
]
)
+ "\n",
encoding="utf-8",
)
return script
def exit_after_update_response() -> None:
time.sleep(1.5)
os._exit(0)
def update_message(available: bool, latest_version: str | None, has_asset: bool) -> str:
if not latest_version:
return "Could not determine the latest release version."
if not available:
return f"TraderAI {__version__} is up to date."
if not has_asset:
return f"TraderAI {latest_version} is available, but the release has no {UPDATE_ASSET_NAME} asset."
return f"TraderAI {latest_version} is available."
def normalize_version(value: str) -> str | None:
text = value.strip()
if text.startswith("v"):
text = text[1:]
parts = text.split(".")
if len(parts) < 2:
return None
return text
def compare_versions(left: str, right: str) -> int:
left_parts = version_parts(left)
right_parts = version_parts(right)
max_len = max(len(left_parts), len(right_parts))
left_parts.extend([0] * (max_len - len(left_parts)))
right_parts.extend([0] * (max_len - len(right_parts)))
return (left_parts > right_parts) - (left_parts < right_parts)
def version_parts(version: str) -> list[int]:
text = normalize_version(version) or "0"
core = text.replace("-", ".").replace("+", ".").split(".")
parts: list[int] = []
for item in core:
digits = ""
for char in item:
if not char.isdigit():
break
digits += char
parts.append(int(digits or 0))
return parts
app = create_app()
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
__version__ = "0.0.1"
RELEASES_URL = "https://git.hudsonriggs.systems/LambdaBankingConglomerate/TraderAI/releases"
RELEASES_API_URL = "https://git.hudsonriggs.systems/api/v1/repos/LambdaBankingConglomerate/TraderAI/releases"