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: 3 additions & 1 deletion cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ readme = "README.md"
requires-python = ">=3.10"
license = { text = "UNLICENSED" }
dependencies = [
"certifi>=2024.2.2",
"click>=8.1.7",
"questionary>=2.1.1",
"websocket-client>=1.9.0",
"PyYAML"
"PyYAML",
"Flask>=3.1.3"
]

[project.scripts]
Expand Down
7 changes: 6 additions & 1 deletion cli/src/stacksync_cli/commands/create.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import os
import re
import shutil
import ssl
import tempfile
import urllib.request
import zipfile

import certifi
import click

from ..utils import with_auth
Expand Down Expand Up @@ -52,7 +54,10 @@ def _get_templates_folder() -> str:
temp_folder = tempfile.mkdtemp()
zip_url = "https://github.com/stacksyncdata/cdk/archive/refs/heads/prod.zip"
zip_path = os.path.join(temp_folder, "cdk.zip")
with urllib.request.urlopen(zip_url) as response, open(zip_path, "wb") as out_file:
ssl_context = ssl.create_default_context(cafile=certifi.where())
with urllib.request.urlopen(zip_url, context=ssl_context) as response, open(
zip_path, "wb"
) as out_file:
shutil.copyfileobj(response, out_file)
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(temp_folder)
Expand Down
53 changes: 52 additions & 1 deletion cli/src/stacksync_cli/commands/dev.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import os
import shutil
import subprocess
import sys
from ..utils import with_auth, with_stacksync_yml
import click

Expand All @@ -11,4 +15,51 @@ def dev(api_key: str, stacksync_yml: dict) -> None:
runs a Stacksync application on localhost,
and uses the local bridge to route traffic to the application.
"""
print("Oh boy", api_key, stacksync_yml)
click.echo("Creating build folder...")
build_dir = _create_build_folder(stacksync_yml)
click.echo("Build folder created.")
click.echo("Starting application on http://127.0.0.1:2323 (Ctrl+C to stop)")
_start_application(build_dir)


def _create_build_folder(stacksync_yml: dict) -> str:
"""
Generates a .stacksync_build folder in the current working directory.
"""
build_dir = os.path.join(os.getcwd(), ".stacksync_build")
os.makedirs(build_dir, exist_ok=True)

# Copy current working directory to the build directory.
shutil.copytree(os.getcwd(), build_dir, dirs_exist_ok=True)

# Inject the app_router.py file as main.py into the build directory.
app_router_path = os.path.join(os.path.dirname(__file__), "..", "generator_files", "app_router.py")
shutil.copy(app_router_path, os.path.join(build_dir, "main.py"))

return build_dir


def _start_application(build_dir: str) -> None:
"""
Run Flask in the build directory without changing the CLI process cwd.

Uses the same interpreter as the CLI (``python -m flask``) and ``--app main``
so the injected ``main.py`` (``app`` instance) is found.
"""
subprocess.run(
[
sys.executable,
"-m",
"flask",
"--app",
"main",
"run",
"--host",
"127.0.0.1",
"--port",
"2323",
],
cwd=build_dir,
check=True,
)

144 changes: 144 additions & 0 deletions cli/src/stacksync_cli/generator_files/app_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
App router for Stacksync Apps defined via stacksync.yml files.
This file is NOT intended to be used directly. It is injected into the build output folder
when users run `stacksync dev` or `stacksync deploy`.
"""

from __future__ import annotations

import importlib.util
import logging
import os
from typing import Any

import yaml
from flask import Flask, current_app, jsonify, request

# Paths are relative to the build output root (e.g. .stacksync_build)
ROOT = os.path.dirname(os.path.abspath(__file__))
MODULES_DIR = os.path.join(ROOT, "modules")



def load_stacksync_yml() -> dict[str, Any]:
path = os.path.join(ROOT, "stacksync.yml")
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}


def import_module_from_path(module_name: str, file_path: str):
"""Load modules/acme_crm_create_record/schema.py as a distinct Python module."""
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load {file_path}")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def create_app() -> Flask:
if not logging.root.handlers:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")

app = Flask(__name__)
app.logger.info("Initializing connector (root=%s)", ROOT)

config = load_stacksync_yml()
app.config["STACKSYNC_YML"] = config

app_settings = config.get("app_settings") or {}
app_name = app_settings.get("app_name", "(unnamed)")
app_type = app_settings.get("app_type", "(unknown)")
app.logger.info("Loaded stacksync.yml: app_name=%r app_type=%r", app_name, app_type)

modules_cfg: dict[str, Any] = config.get("modules") or {}
if not modules_cfg:
app.logger.warning("No modules declared under stacksync.yml 'modules'")

for module_id, module_meta in modules_cfg.items():
package_dir = os.path.join(MODULES_DIR, module_id)
if not os.path.isdir(package_dir):
app.logger.warning("Skipping module %r: no directory at %s", module_id, package_dir)
continue

prefix = f"/modules/{module_id}"
registered: list[str] = []

# schema.py → schema_handler(form_data, credentials)
schema_path = os.path.join(package_dir, "schema.py")
if os.path.isfile(schema_path):
smod = import_module_from_path(f"{module_id}_schema", schema_path)

@app.route(f"{prefix}/schema", methods=["GET", "POST"])
def schema_route(
_smod=smod,
_meta=module_meta,
_module_id=module_id,
):
current_app.logger.info("Schema handler called (module=%s)", _module_id)
payload = request.get_json(silent=True) or {}
form_data = payload.get("form_data") or {}
credentials = payload.get("credentials")
result = _smod.schema_handler(form_data, credentials)
# schema_handler may return a Schema (dict subclass) or plain dict
return jsonify({"schema": dict(result) if hasattr(result, "keys") else result})

registered.append("schema")

# content.py → content_handler(form_data, credentials, content_object_names)
content_path = os.path.join(package_dir, "content.py")
if os.path.isfile(content_path):
cmod = import_module_from_path(f"{module_id}_content", content_path)

@app.route(f"{prefix}/content", methods=["POST"])
def content_route(_cmod=cmod, _module_id=module_id):
current_app.logger.info("Content handler called (module=%s)", _module_id)
payload = request.get_json(silent=True) or {}
form_data = payload.get("form_data") or {}
credentials = payload.get("credentials")
names = payload.get("content_object_names") or []
result = _cmod.content_handler(form_data, credentials, names)
return jsonify(result)

registered.append("content")

# execute.py → execute_handler(input, credentials)
execute_path = os.path.join(package_dir, "execute.py")
if os.path.isfile(execute_path):
emod = import_module_from_path(f"{module_id}_execute", execute_path)

@app.route(f"{prefix}/execute", methods=["POST"])
def execute_route(_emod=emod, _module_id=module_id):
current_app.logger.info("Execute handler called (module=%s)", _module_id)
payload = request.get_json(silent=True) or {}
data = payload.get("data") or {}
credentials = payload.get("credentials") or {}
result = _emod.execute_handler(data, credentials)
return jsonify(result)

registered.append("execute")

if registered:
app.logger.info(
"Registered module %r: %s",
module_id,
", ".join(registered),
)
else:
app.logger.warning(
"Module %r: no schema.py, content.py, or execute.py found",
module_id,
)

@app.get("/health")
def health():
return jsonify({"status": "ok"})

app.logger.info("Routes ready (includes GET /health)")
return app


app = create_app()

if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000, debug=True)
1 change: 1 addition & 0 deletions cli/src/stacksync_cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def with_stacksync_yml(func):
@wraps(func)
def wrapper(*args, **kwargs):
# Get current directory
click.echo("Current directory: " + os.getcwd())
current_dir = os.getcwd()
stacksync_yml_path = os.path.join(current_dir, "stacksync.yml")
if not os.path.exists(stacksync_yml_path):
Expand Down
6 changes: 4 additions & 2 deletions cli/stacksync.spec
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
from PyInstaller.utils.hooks import collect_submodules
from PyInstaller.utils.hooks import collect_data_files, collect_submodules


# questionary pulls in prompt_toolkit modules dynamically, so collect them
# explicitly to keep the standalone binary interactive on all platforms.
hiddenimports = collect_submodules("questionary")

# PyInstaller onefile builds do not ship the OS CA store; bundle certifi's PEM.
datas = collect_data_files("certifi")

a = Analysis(
["src/stacksync_cli/__main__.py"],
pathex=["src"],
binaries=[],
datas=[],
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
Expand Down
3 changes: 3 additions & 0 deletions lib/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ version = "0.1.0"
description = "Stacksync CDK library for connector modules (ApiClient, schema utilities)"
requires-python = ">=3.10"
license = { text = "UNLICENSED" }
dependencies = [
"PyYAML>=6.0",
]

[tool.hatch.build.targets.wheel]
packages = ["stacksync_cdk"]
4 changes: 2 additions & 2 deletions lib/stacksync_cdk/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Stub implementation of stacksync_cdk for local development and template resolution."""

from stacksync_cdk.api_client import ApiClient
from stacksync_cdk.schema_utils import load_schema, to_schema
from stacksync_cdk.schema_utils import Schema, load_schema, to_schema, validate_schema_dict

__all__ = ["ApiClient", "load_schema", "to_schema"]
__all__ = ["ApiClient", "Schema", "load_schema", "to_schema", "validate_schema_dict"]
45 changes: 32 additions & 13 deletions lib/stacksync_cdk/api_client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
from __future__ import annotations

from collections.abc import Callable
from typing import Any, TypeVar
import functools
import logging
from abc import abstractmethod
from collections.abc import Callable
from typing import Any, TypeVar, cast

from stacksync_cdk.schema_utils import validate_schema_dict

F = TypeVar("F", bound=Callable[..., Any])

_logger = logging.getLogger(__name__)


class ApiClient:
"""
Expand Down Expand Up @@ -40,20 +46,33 @@ def _authenticate(self) -> None:
raise ValueError("No credentials provided")
self.authenticate()

@classmethod
def as_schema(cls) -> Callable[[F], F]:
"""Decorator that validates return values as Stacksync schema (stub)."""

def decorator(fn: F) -> F:
return fn

return decorator

@classmethod
def returns_schema(cls) -> Callable[[F], F]:
"""Decorator that validates return values as Stacksync schema (stub)."""
"""
Decorator for methods that return a Stacksync module schema dict or fragment.

After the wrapped method runs, the return value is validated with
:func:`~stacksync_cdk.schema_utils.validate_schema_dict`. If validation fails,
an error is logged (with the method name) and the exception is re-raised.
"""

def decorator(fn: F) -> F:
return fn
@functools.wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
result = fn(*args, **kwargs)
label = f"{fn.__module__}.{fn.__qualname__}"
try:
validate_schema_dict(result, source=label)
except (TypeError, ValueError) as e:
_logger.error(
"Invalid Stacksync schema returned from %s: %s",
label,
e,
)
raise
return result

return cast(F, wrapper)

return decorator

Loading
Loading