Compare commits

...
Author SHA1 Message Date
Anthony Stirling 7798d68e7f Add stirling-pdf pip installer and SDK generation task 2026-07-30 22:54:45 +01:00
7 changed files with 390 additions and 0 deletions
+3
View File
@@ -290,3 +290,6 @@ docs/type3/signatures/
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
# Generated API client SDKs (task sdk:generate)
clients/
+6
View File
@@ -57,6 +57,12 @@ tasks:
- task: frontend:install
- task: engine:install
sdk:generate:
desc: "Generate Python/Node/Go client SDKs from the OpenAPI spec"
cmds:
- task: backend:swagger
- bash devTools/generate-sdks.sh
# ============================================================
# Development
# ============================================================
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Generate API client SDKs (Python, TypeScript/Node, Go) from SwaggerDoc.json.
# Build-time only: nothing here ships in any image. Output lands in clients/
# which is gitignored; publish steps live with each package registry.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SPEC="$ROOT/SwaggerDoc.json"
GENERATOR_VERSION=7.24.0
JAR="$ROOT/build/openapi-generator-cli-$GENERATOR_VERSION.jar"
OUT="$ROOT/clients"
if [ ! -f "$SPEC" ]; then
echo "SwaggerDoc.json missing - run 'task backend:swagger' first" >&2
exit 1
fi
if [ ! -f "$JAR" ]; then
mkdir -p "$ROOT/build"
echo "Fetching openapi-generator-cli $GENERATOR_VERSION"
curl -fsSL -o "$JAR" \
"https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/$GENERATOR_VERSION/openapi-generator-cli-$GENERATOR_VERSION.jar"
fi
generate() {
local generator="$1" out_dir="$2"; shift 2
echo "Generating $generator -> $out_dir"
java -jar "$JAR" generate -i "$SPEC" -g "$generator" -o "$OUT/$out_dir" \
--skip-validate-spec "$@"
}
rm -rf "$OUT"
generate python python \
--additional-properties=packageName=stirling_pdf_client,projectName=stirling-pdf-client
generate typescript-node node \
--additional-properties=npmName=@stirling-pdf/client,supportsES6=true
generate go go \
--additional-properties=packageName=stirlingpdf
echo "SDKs generated under clients/"
+41
View File
@@ -0,0 +1,41 @@
# stirling-pdf (pip launcher)
Install and run Stirling PDF with pip:
```bash
pip install stirling-pdf
stirling-pdf run
```
What it does:
- **Docker available** (recommended): generates a compose file under
`~/.stirling-pdf/`, pulls the official image, and starts it on
`http://localhost:8080`. `--variant fat|ultra-lite` picks the image,
`--docparse` also runs the AI engine with the DocParse addon (layout
parsing, grounded extraction; ~1.6 GB one-time download onto a volume).
- **No Docker**: downloads the release jar and runs it with your local
Java 21+ (`--no-docker` forces this path). The DocParse advanced tier is
Docker-only; the jar still serves everything else.
Commands:
```bash
stirling-pdf run [--port 8080] [--variant latest|fat|ultra-lite] [--docparse] [--no-docker]
stirling-pdf status
stirling-pdf update
stirling-pdf stop
stirling-pdf addons install docparse
stirling-pdf addons remove docparse
```
State lives in `~/.stirling-pdf` (override with `STIRLING_PDF_HOME`). The
package has zero Python dependencies.
## Publishing (maintainers)
```bash
cd packaging/pip
python -m build
python -m twine upload dist/*
```
+34
View File
@@ -0,0 +1,34 @@
[project]
name = "stirling-pdf"
version = "0.1.0"
description = "Installer and launcher for Stirling PDF: runs the Docker images when Docker is available, or the release jar with a local Java 21+."
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [{ name = "Stirling PDF", email = "hello@stirlingpdf.com" }]
keywords = ["pdf", "stirling", "stirling-pdf", "document"]
classifiers = [
"Environment :: Console",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Topic :: Office/Business",
]
# Deliberately zero dependencies: stdlib only, so `pip install stirling-pdf`
# never conflicts with anything.
dependencies = []
[project.urls]
Homepage = "https://stirlingpdf.com"
Source = "https://github.com/Stirling-Tools/Stirling-PDF"
[project.scripts]
stirling-pdf = "stirling_pdf.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/stirling_pdf"]
@@ -0,0 +1,3 @@
"""stirling-pdf: pip-installable launcher for Stirling PDF."""
__version__ = "0.1.0"
+263
View File
@@ -0,0 +1,263 @@
"""Stirling PDF launcher CLI.
Prefers Docker (compose file generated under ~/.stirling-pdf); falls back to
downloading the release jar and running it with a local Java 21+. Stdlib only.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
APP_IMAGE = "stirlingtools/stirling-pdf"
ENGINE_IMAGE = "stirlingtools/stirling-pdf-engine"
JAR_URL_TEMPLATE = "https://github.com/Stirling-Tools/Stirling-PDF/releases/{release}/download/{asset}"
JAR_ASSETS = ("Stirling-PDF.jar", "Stirling-PDF-with-login.jar")
COMPOSE_PROJECT = "stirling-pdf"
def home_dir() -> Path:
home = Path(os.environ.get("STIRLING_PDF_HOME", Path.home() / ".stirling-pdf"))
home.mkdir(parents=True, exist_ok=True)
return home
def load_state() -> dict:
path = home_dir() / "config.json"
if path.exists():
try:
return json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
pass
return {}
def save_state(state: dict) -> None:
(home_dir() / "config.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
# ---------------------------------------------------------------- docker mode
def docker_available() -> bool:
if shutil.which("docker") is None:
return False
try:
result = subprocess.run(
["docker", "info"], capture_output=True, text=True, timeout=30, check=False
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def compose_yaml(port: int, variant: str, docparse: bool) -> str:
tag = "latest" if variant == "latest" else f"latest-{variant}"
home = home_dir()
for sub in ("data", "config", "logs", "docparse"):
(home / sub).mkdir(exist_ok=True)
app_env = [
" DOCKER_ENABLE_SECURITY: \"false\"",
]
engine_service = ""
if docparse:
app_env += [
" AIENGINE_ENABLED: \"true\"",
" AIENGINE_URL: http://stirling-pdf-engine:5001",
" DOCPARSE_ENABLED: \"true\"",
" DOCPARSE_MODE: auto",
]
engine_service = f"""
stirling-pdf-engine:
image: {ENGINE_IMAGE}:latest
restart: unless-stopped
environment:
DOCPARSE_AUTO_INSTALL: "true"
volumes:
- {(home / 'docparse').as_posix()}:/configs/docparse
"""
return f"""# Generated by `stirling-pdf run` - edit and re-run, or delete to reset.
services:
stirling-pdf:
image: {APP_IMAGE}:{tag}
restart: unless-stopped
ports:
- "{port}:8080"
volumes:
- {(home / 'data').as_posix()}:/usr/share/tessdata
- {(home / 'config').as_posix()}:/configs
- {(home / 'logs').as_posix()}:/logs
environment:
{chr(10).join(app_env)}
{engine_service}"""
def compose(*args: str) -> int:
return subprocess.call(
["docker", "compose", "-p", COMPOSE_PROJECT, "-f", str(home_dir() / "docker-compose.yml"), *args]
)
def run_docker(port: int, variant: str, docparse: bool) -> int:
compose_path = home_dir() / "docker-compose.yml"
compose_path.write_text(compose_yaml(port, variant, docparse), encoding="utf-8")
save_state({**load_state(), "mode": "docker", "port": port, "variant": variant, "docparse": docparse})
code = compose("up", "-d", "--pull", "always")
if code == 0:
print(f"Stirling PDF is starting: http://localhost:{port}")
if docparse:
print("DocParse addon: engine is downloading it on first boot (~1.6 GB, one-time).")
return code
# ------------------------------------------------------------------- jar mode
def java_major_version() -> int | None:
java = shutil.which("java")
if java is None:
return None
try:
result = subprocess.run([java, "-version"], capture_output=True, text=True, timeout=30, check=False)
except (OSError, subprocess.TimeoutExpired):
return None
match = re.search(r'version "(\d+)', result.stderr or result.stdout or "")
return int(match.group(1)) if match else None
def download(url: str, target: Path) -> bool:
print(f"Downloading {url}")
try:
with urllib.request.urlopen(url, timeout=600) as response: # noqa: S310
total = int(response.headers.get("Content-Length") or 0)
read = 0
tmp = target.with_suffix(".part")
with open(tmp, "wb") as out:
while True:
chunk = response.read(1 << 20)
if not chunk:
break
out.write(chunk)
read += len(chunk)
if total:
sys.stdout.write(f"\r {read // (1 << 20)} / {total // (1 << 20)} MB")
sys.stdout.flush()
sys.stdout.write("\n")
tmp.replace(target)
return True
except (urllib.error.URLError, OSError) as error:
print(f" download failed: {error}", file=sys.stderr)
return False
def run_jar(port: int, version: str) -> int:
major = java_major_version()
if major is None:
print("Neither Docker nor Java found. Install Docker (recommended) or Java 21+.", file=sys.stderr)
return 1
if major < 21:
print(f"Java {major} found but Stirling PDF needs Java 21+.", file=sys.stderr)
return 1
jar = home_dir() / "Stirling-PDF.jar"
if not jar.exists():
release = "latest" if version == "latest" else f"download/v{version}"
if not any(download(JAR_URL_TEMPLATE.format(release=release, asset=asset), jar) for asset in JAR_ASSETS):
return 1
save_state({**load_state(), "mode": "jar", "port": port})
env = {**os.environ, "SERVER_PORT": str(port), "STIRLING_BASE_PATH": str(home_dir() / "jar-home")}
print(f"Starting Stirling PDF on http://localhost:{port} (Ctrl-C to stop)")
print("Note: the jar runs the core app; DocParse advanced tier needs the Docker engine image.")
return subprocess.call(["java", "-jar", str(jar)], env=env)
# ------------------------------------------------------------------ commands
def cmd_run(args: argparse.Namespace) -> int:
if not args.no_docker and docker_available():
return run_docker(args.port, args.variant, args.docparse)
if args.docparse:
print("--docparse needs Docker (the addon ships in the engine image).", file=sys.stderr)
return 1
return run_jar(args.port, args.version)
def cmd_stop(_args: argparse.Namespace) -> int:
state = load_state()
if state.get("mode") == "docker":
return compose("down")
print("Jar mode runs in the foreground; stop it with Ctrl-C.", file=sys.stderr)
return 1
def cmd_status(_args: argparse.Namespace) -> int:
state = load_state()
if state.get("mode") == "docker":
return compose("ps")
print(json.dumps(state or {"mode": "not configured"}, indent=2))
return 0
def cmd_update(_args: argparse.Namespace) -> int:
state = load_state()
if state.get("mode") == "docker":
code = compose("pull")
return code or compose("up", "-d")
jar = home_dir() / "Stirling-PDF.jar"
if jar.exists():
jar.unlink()
print("Removed cached jar; the next `stirling-pdf run` downloads the latest release.")
return 0
def cmd_addons(args: argparse.Namespace) -> int:
if args.addon != "docparse":
print(f"Unknown addon: {args.addon}. Available: docparse", file=sys.stderr)
return 1
state = load_state()
if state.get("mode") != "docker":
print("Addons need Docker mode; run `stirling-pdf run` with Docker available first.", file=sys.stderr)
return 1
enable = args.action == "install"
return run_docker(state.get("port", 8080), state.get("variant", "latest"), enable)
def main() -> int:
parser = argparse.ArgumentParser(prog="stirling-pdf", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
run_parser = sub.add_parser("run", help="Start Stirling PDF (Docker preferred, jar fallback)")
run_parser.add_argument("--port", type=int, default=8080)
run_parser.add_argument("--variant", choices=["latest", "fat", "ultra-lite"], default="latest")
run_parser.add_argument("--version", default="latest", help="Release version for jar mode")
run_parser.add_argument("--docparse", action="store_true", help="Also run the AI engine with the DocParse addon")
run_parser.add_argument("--no-docker", action="store_true", help="Force jar mode")
run_parser.set_defaults(func=cmd_run)
sub.add_parser("stop", help="Stop the Docker deployment").set_defaults(func=cmd_stop)
sub.add_parser("status", help="Show what is running").set_defaults(func=cmd_status)
sub.add_parser("update", help="Pull/download the newest release").set_defaults(func=cmd_update)
addons = sub.add_parser("addons", help="Manage optional addons")
addons.add_argument("action", choices=["install", "remove"])
addons.add_argument("addon")
addons.set_defaults(func=cmd_addons)
args = parser.parse_args()
return args.func(args)
if __name__ == "__main__":
sys.exit(main())