diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2343524 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# SystemLink Function Services Configuration +# These environment variables can be set in this .env file for local development + +# Unified Function Management Service URL (definitions + executions) +# FUNCTION_SERVICE_URL=http://localhost:8080 + +# Legacy separate execution service URL is no longer used: +# (FUNCTION_EXECUTION_SERVICE_URL has been deprecated) + +# Or specify a common base URL used to derive the unified service path +# SYSTEMLINK_API_URL=http://localhost:8080 + +# API Key for authentication +# SYSTEMLINK_API_KEY=your_api_key_here + +# SSL Verification (set to false for local development with self-signed certificates) +# SLCLI_SSL_VERIFY=false diff --git a/.gitignore b/.gitignore index d44e076..79e0c45 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,4 @@ tests/unit/__pycache__/ # E2E test configuration tests/e2e/e2e_config.json +.env diff --git a/README.md b/README.md index 47bac8a..2c8b225 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ SystemLink CLI (`slcli`) is a cross-platform Python CLI for SystemLink integrato ## Features - **Secure Authentication**: Credential storage using [keyring](https://github.com/jaraco/keyring) with `login`/`logout` commands +- **Function Management**: Complete WebAssembly (WASM) function definition and execution management with metadata-driven organization - **Test Plan Templates**: Complete management (list, export, import, delete, init) with JSON and table output formats - **Jupyter Notebooks**: Full lifecycle management (list, download, create, update, delete) with workspace filtering - **User Management**: Comprehensive user administration (list, get, create, update, delete) with Dynamic LINQ filtering and pagination @@ -14,6 +15,7 @@ SystemLink CLI (`slcli`) is a cross-platform Python CLI for SystemLink integrato - **Professional CLI**: Consistent error handling, colored output, and comprehensive help system - **Output Formats**: JSON and table output options for programmatic integration and human-readable display - **Template Initialization**: Create new template JSON files +- **Local Development Support**: .env file support for configuring service URLs during development - **Extensible Architecture**: Designed for easy addition of new SystemLink resource types - **Quality Assurance**: Full test suite with CI/CD, linting, and manual E2E testing @@ -100,6 +102,12 @@ After installation, restart your shell or source the completion file. See [docs/ # View test plan templates slcli template list + # View function definitions + slcli function manage list + + # View function executions + slcli function execute list + # View workflows slcli workflow list @@ -288,6 +296,576 @@ slcli workflow update --id --file updated-workflow.json slcli workflow delete --id ``` +## Function Management + +The `function` command group provides comprehensive management of WebAssembly (WASM) function definitions and executions in SystemLink. Functions are compiled WebAssembly modules that can be executed remotely with parameters. + +**Architecture Overview (Unified v2):** + +- **Unified Function Management Service** (`/nifunction/v2`): Single API surface for definitions, metadata, WASM content, and synchronous execution +- **Interface Property**: Indexed JSON (legacy simple entrypoint or HTTP-style `endpoints` with `methods`, `path`, and `description`) +- **Workspace Integration**: Functions belong to SystemLink workspaces with metadata and execution statistics +- **Synchronous HTTP-style Execution**: POST `/functions/{id}/execute` with parameters `{ method, path, headers, body }` + +The CLI provides two main command groups: + +- `slcli function manage` - Function definition management (create, update, delete, query) +- `slcli function execute` - Function execution management (synchronous execution, list, get, cancel, retry) + +### Function Definition Management (`function manage`) + +#### List function definitions + +```bash +# List all function definitions (table format - default) +slcli function manage list + +# Filter by workspace +slcli function manage list --workspace MyWorkspace + +# Filter by name pattern (starts with) +slcli function manage list --name "data_" + +# Filter by interface content (searches interface property for text) +slcli function manage list --interface-contains "entrypoint" + +# Advanced Dynamic LINQ filtering +slcli function manage list --filter 'name.StartsWith("data") && interface.Contains("entrypoint")' + +# JSON format for programmatic use +slcli function manage list --format json + +# Control pagination +slcli function manage list --take 50 +``` + +#### Get function definition details + +```bash +# Get detailed information about a function +slcli function manage get --id + +# JSON format +slcli function manage get --id --format json +``` + +#### Create a new function definition + +```bash +# Simple example using the provided sample WASM file +slcli function manage create \ + --name "Sample Math Calculator - add" \ + --runtime wasm \ + --content ./samples/math.wasm \ + --entrypoint add \ + --workspace "Default" \ + --description "Simple mathematical operations using WebAssembly" + +# Create a basic WASM function with interface definition +slcli function manage create \ + --name "Data Processing Function" \ + --runtime wasm \ + --workspace MyWorkspace \ + --description "Processes sensor data and calculates statistics" \ + --version "1.0.0" \ + --entrypoint "main" + +# Create with WASM binary from file and schema definitions +slcli function manage create \ + --name "Signal Analyzer" \ + --runtime wasm \ + --content ./signal_analyzer.wasm \ + --workspace "Production Workspace" \ + --description "Analyzes signal patterns and detects anomalies" \ + --entrypoint "analyze_signal" \ + --parameters-schema '{"type": "object", "properties": {"samples": {"type": "array", "items": {"type": "number"}}, "threshold": {"type": "number"}}, "required": ["samples"]}' \ + --returns-schema '{"type": "object", "properties": {"anomalies": {"type": "array"}, "confidence": {"type": "number"}}}' + +# Create with custom properties for organization and filtering +slcli function manage create \ + --name "Customer Analytics Engine" \ + --runtime wasm \ + --content ./analytics.wasm \ + --workspace "Analytics Workspace" \ + --description "Customer behavior analysis and prediction" \ + --version "2.1.0" \ + --entrypoint "process_customer_data" \ + --properties '{"category": "analytics", "team": "data-science", "deployment": "production", "compliance": "gdpr"}' \ + --parameters-schema '{"type": "object", "properties": {"customer_id": {"type": "string"}, "timeframe": {"type": "string"}, "metrics": {"type": "array", "items": {"type": "string"}}}, "required": ["customer_id"]}' \ + --returns-schema '{"type": "object", "properties": {"predictions": {"type": "object"}, "confidence_score": {"type": "number"}, "recommendation": {"type": "string"}}}' + +# Create with inline interface content (demonstrates interface property structure) +slcli function manage create \ + --name "Mathematical Calculator" \ + --runtime wasm \ + --workspace "Default" \ + --description "High-performance mathematical operations library" \ + --version "1.0.0" \ + --entrypoint "calculate" \ + --parameters-schema '{"type": "object", "properties": {"operation": {"type": "string", "enum": ["add", "subtract", "multiply", "divide"]}, "operands": {"type": "array", "items": {"type": "number"}, "minItems": 2}}, "required": ["operation", "operands"]}' \ + --properties '{"category": "utilities", "team": "platform", "tags": "math,calculator,utility"}' +``` + +##### Example: Creating a Function with the Sample WASM File + +This repository includes a sample WebAssembly function (`samples/math.wasm`) that demonstrates basic mathematical operations. Here's how to create a function using this sample: + +````bash +# Create a function using the provided sample WASM file +slcli function manage create \ + --name "Sample Math Functions - fred 1" \ + --runtime wasm \ + --content ./samples/math.wasm \ + --workspace "Default" \ + --description "Sample WebAssembly function with add, multiply_and_add, and execute operations" \ + --version "1.0.0" \ + --entrypoint "execute" \ + --properties '{"category": "samples", "team": "development", "tags": "demo,math,sample", "runtime_type": "wasm"}' \ + --parameters-schema '{"type": "object", "properties": {"a": {"type": "integer", "description": "First operand"}, "b": {"type": "integer", "description": "Second operand"}, "c": {"type": "integer", "description": "Third operand (optional)"}}, "required": ["a", "b"]}' \ + --returns-schema '{"type": "integer", "description": "Computed result"}' + +# The math.wasm file exports these functions: +# - add(a, b): Returns a + b +# - multiply_and_add(a, b, c): Returns (a * b) + c +# - execute(): Returns 42 (main entry point) + +# After creation, you can execute the function synchronously (HTTP-style parameters): +slcli function execute sync \ + --function-id \ + --method POST \ + --path /invoke \ + -H content-type=application/json \ + --body '{"a":10,"b":5,"c":3}' \ + --timeout 300 --format json + +### Initialize a Local Function Template (`function init`) + +Bootstrap a local template for building a function from official examples. + +```bash +# Prompt for language +slcli function init + +# TypeScript (Hono) template into a new folder +slcli function init --language typescript --directory my-ts-func + +# Python HTTP template +slcli function init -l python -d my-py-func + +# Overwrite non-empty directory +slcli function init -l ts -d existing --force +```` + +Templates are fetched on-demand from branch `function-examples` of `ni/systemlink-enterprise-examples`: + +- TypeScript: `function-examples/typescript-hono-function` +- Python: `function-examples/python-http-function` + +Next steps (printed only): + +1. Install dependencies / create venv +2. Build (TypeScript: `npm run build` → `dist/main.wasm`) +3. Register with `slcli function manage create --content dist/main.wasm --entrypoint main` (adjust name/workspace) + +To supply HTTP-style execution parameters later: + +```bash +slcli function execute sync --function-id --method GET --path / +``` + +#### Enhanced Filtering and Querying + +Use interface-based filtering and custom properties for efficient function management based on the function's interface definition: + +```bash +# Filter by interface content (searches within the indexed interface property) +slcli function manage list --interface-contains "entrypoint" + +# Search for functions with specific entrypoints +slcli function manage list --interface-contains "process_data" + +# Advanced Dynamic LINQ filtering using interface properties +slcli function manage list --filter 'interface.entrypoint != null && interface.entrypoint != "" && runtime = "wasm"' + +# Filter by custom properties for organizational management +slcli function manage list --filter 'properties.category == "analytics" && properties.deployment == "production"' + +# Search for functions by team and performance characteristics +slcli function manage list --filter 'properties.team == "data-science" && properties.accuracy > 0.9' + +# Find functions suitable for specific environments +slcli function manage list --filter 'properties.deployment == "production" && properties.compliance == "gdpr"' + +# Search within interface content for specific parameter types +slcli function func list --filter 'interface.Contains("customer_id") && interface.Contains("timeframe")' + +# Complex filtering combining multiple criteria +slcli function func list \ + --workspace "Analytics Workspace" \ + --name "Customer" \ + --filter 'properties.category == "analytics" && interface.entrypoint != null' + +# Find functions with specific runtime and interface characteristics +slcli function func list --filter 'runtime = "wasm" && interface.Contains("parameters") && !string.IsNullOrEmpty(properties.team)' +``` + +#### Update a function definition + +```bash +# Update function metadata +slcli function func update \ + --id \ + --name "Updated Function Name" \ + --description "Updated description" \ + --version "1.1.0" + +# Update function WASM binary +slcli function func update \ + --id \ + --content ./updated_function.wasm + +# Update WebAssembly binary +slcli function func update \ + --id \ + --content ./updated_math_functions.wasm + +# Update parameters schema +slcli function func update \ + --id \ + --parameters-schema ./new_params_schema.json + +# Update metadata and properties +slcli function func update \ + --id \ + --properties '{"deployment": "production", "team": "platform-team", "critical": true}' + +# Update workspace and runtime +slcli function func update \ + --id \ + --workspace "Production Workspace" \ + --runtime wasm + +# Update with custom properties (replaces existing properties) +slcli function func update \ + --id \ + --properties '{"deployment": "production", "version": "2.0", "critical": true}' +``` + +#### Delete a function definition + +```bash +# Delete with confirmation prompt +slcli function func delete --id + +# Delete without confirmation +slcli function func delete --id --force +``` + +#### Complete Workflow Example + +Here's a complete example showing how to use the interface-based function system for efficient metadata management: + +```bash +# 1. Create a customer analytics function with comprehensive interface definition +slcli function func create \ + --name "Customer Analytics Engine" \ + --runtime wasm \ + --content ./customer_analytics.wasm \ + --workspace "Analytics Workspace" \ + --description "Customer behavior analysis and prediction engine" \ + --version "2.1.0" \ + --entrypoint "analyze_customer_behavior" \ + --properties '{"category": "analytics", "team": "data-science", "deployment": "production", "compliance": "gdpr", "sla": "4h"}' \ + --parameters-schema '{"type": "object", "properties": {"customer_id": {"type": "string"}, "timeframe": {"type": "string", "enum": ["7d", "30d", "90d"]}, "metrics": {"type": "array", "items": {"type": "string"}}}, "required": ["customer_id", "timeframe"]}' \ + --returns-schema '{"type": "object", "properties": {"predictions": {"type": "object"}, "confidence_score": {"type": "number", "minimum": 0, "maximum": 1}, "recommendations": {"type": "array"}}}' + +# 2. Create a complementary reporting function for the same team +slcli function func create \ + --name "Customer Report Generator" \ + --runtime wasm \ + --content ./report_generator.wasm \ + --workspace "Analytics Workspace" \ + --description "Generates formatted customer analysis reports" \ + --version "1.0.0" \ + --entrypoint "generate_report" \ + --properties '{"category": "reporting", "team": "data-science", "deployment": "production", "output_format": "pdf"}' \ + --parameters-schema '{"type": "object", "properties": {"analysis_id": {"type": "string"}, "format": {"type": "string", "enum": ["pdf", "html", "json"]}, "include_charts": {"type": "boolean"}}, "required": ["analysis_id"]}' \ + --returns-schema '{"type": "object", "properties": {"report_url": {"type": "string"}, "size_bytes": {"type": "integer"}, "generated_at": {"type": "string", "format": "date-time"}}}' + +# 3. Query functions by team and deployment status using interface-based filtering +slcli function func list --filter 'properties.team == "data-science" && properties.deployment == "production"' + +# 4. Find functions with specific interface characteristics (customer-related functions) +slcli function func list --filter 'interface.Contains("customer_id") && runtime == "wasm"' + +# 5. Search for functions with specific entrypoints +slcli function func list --interface-contains "analyze_customer" + +# 6. Execute the analytics function with real parameters +slcli function execute \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --workspace "Analytics Workspace" \ + --parameters '{"functionName": "analyze_customer_behavior", "args": ["CUST-2025-001", "30d", ["engagement", "conversion", "retention"]]}' \ + --timeout 600 \ + --client-request-id "customer-analysis-$(date +%s)" + +# 7. Create an asynchronous batch job for multiple customers +slcli function create \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --workspace "Analytics Workspace" \ + --parameters '{"functionName": "batch_analyze", "args": ["BATCH-2025-001", "90d", ["lifetime_value", "churn_risk"]]}' \ + --timeout 3600 \ + --result-cache-period 86400 \ + --client-request-id "batch-analytics-20250805" + +# 8. Update function properties when promoting through environments +slcli function func update \ + --id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --properties '{"category": "analytics", "team": "data-science", "deployment": "production", "compliance": "gdpr", "sla": "4h", "monitoring": true, "alerts": "enabled"}' + +# 9. Query production functions with monitoring and compliance requirements +slcli function func list --filter 'properties.deployment == "production" && properties.monitoring == true && properties.compliance == "gdpr"' + +# 10. Find functions with specific interface capabilities for API documentation +slcli function func list --filter 'interface.Contains("timeframe") && interface.Contains("customer_id") && properties.category == "analytics"' +``` + +This metadata system enables you to: + +- **Organize** functions by category, team, and purpose using custom properties +- **Filter efficiently** using interface-based queries and property filters +- **Track** deployment status and operational metadata +- **Search** using flexible custom properties and interface content +- **Manage** functions across multiple teams and environments + +#### Download function source code + +```bash +# Download function content with automatic file extension detection +slcli function func download-content --id + +# Download function content to a specific file +slcli function func download-content --id --output my_function.wasm + +# Download WebAssembly binary +slcli function func download-content --id --output math_functions.wasm +``` + +_Note: Functions are WebAssembly modules and will be downloaded with the .wasm extension._ + +### Function Execution Management + +Execution now uses an HTTP-style invocation parameters object. The `parameters` field sent to +the execute endpoint has this structure: + +```json +{ + "method": "POST", // optional (default POST) + "path": "/invoke", // optional (default /invoke) + "headers": { + // optional headers map + "content-type": "application/json" + }, + "body": { + // JSON object or raw string; if omitted, empty body + "a": 1, + "b": 2 + } +} +``` + +CLI convenience flags build this object automatically: + +- `--method` (default POST) +- `--path` (default /invoke) +- `-H/--header key=value` (repeatable) +- `--body` (JSON string, JSON file path, or raw text) +- `--parameters` (raw JSON overrides the above flags). If the provided JSON does **not** contain + any of `method`, `path`, `headers`, or `body`, the value is wrapped automatically as `{ "body": }` for backward compatibility. + +#### JavaScript Fetch Example (Equivalent to CLI) + +```javascript +// Synchronous execution (default method POST to /invoke) +await fetch(`/nifunction/v2/functions/${functionId}/execute`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + parameters: { + method: "POST", // optional + path: "/invoke", // optional + headers: { "content-type": "application/json" }, + body: { a: 1, b: 2 }, + }, + timeout: 30, + async: false, + }), +}); +``` + +#### CLI Equivalent + +```bash +slcli function execute sync \ + --function-id \ + --method POST \ + --path /invoke \ + -H content-type=application/json \ + --body '{"a":1,"b":2}' \ + --timeout 30 \ + --format json +``` + +Or with a raw parameters JSON object: + +```bash +slcli function execute sync \ + --function-id \ + --parameters '{"method":"POST","path":"/invoke","headers":{"content-type":"application/json"},"body":{"a":1,"b":2}}' \ + --timeout 30 --format json +``` + +Backward compatibility: passing `--parameters '{"a":1,"b":2}'` will be interpreted as body payload. + +#### List function executions + +```bash +# List all function executions (table format - default) +slcli function list + +# Filter by workspace +slcli function list --workspace MyWorkspace + +# Filter by status +slcli function list --status SUCCEEDED + +# Filter by function ID +slcli function list --function-id + +# JSON format for programmatic use +slcli function list --format json + +# Control pagination +slcli function list --take 50 +``` + +#### Get execution details + +```bash +# Get detailed information about an execution +slcli function get --id + +# JSON format +slcli function get --id --format json +``` + +#### Execute a function synchronously + +```bash +# Execute a function and wait for the result (basic usage) +# Note: For WASM functions, use functionName + args structure +slcli function execute sync \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --method POST \ + --path /invoke \ + -H content-type=application/json \ + --body '{"samples":[1.0,2.5,3.2,1.8],"threshold":2.0}' + +# Execute with parameters from file +# Note: Parameter files should contain the new WASM structure: +# { +# "functionName": "add", +# "args": [10, 5] +# } +slcli function execute sync \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --parameters ./execution_params.json \ + --timeout 300 + +# Execute with comprehensive configuration (matches ExecuteFunctionRequest schema) +slcli function execute sync \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --parameters '{"method":"POST","path":"/invoke","body":{"customerId":"CUST-12345","timeframe":"30d","metrics":["engagement","conversion"]}}' \ + --timeout 1800 \ + --client-request-id "analytics-req-20250805-001" + +# JSON format for programmatic integration (returns ExecuteFunctionResponse) +slcli function execute sync \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --parameters '{"method":"POST","path":"/invoke","body":{"op":"multiply","a":4,"b":7}}' \ + --format json + +# Execute mathematical function with comprehensive tracking +slcli function execute sync \ + --function-id b7cc0156-931c-472f-a027-d88dc51cb936 \ + --parameters '{"method":"POST","path":"/invoke","body":{"op":"add","a":15,"b":25}}' \ + --timeout 300 \ + --client-request-id "math-calc-$(date +%s)" \ + --format json +``` + +#### (Removed) Asynchronous execution + +Asynchronous execution support has been removed from the CLI. All executions use the synchronous +endpoint; for background workloads, orchestrate via external tooling that schedules synchronous +invocations. + +#### Cancel function executions + +```bash +# Cancel a single execution +slcli function cancel --id 6d958d07-2d85-4655-90ba-8ff84a0482aa + +# Cancel multiple executions (bulk operation) +slcli function cancel \ + --id 6d958d07-2d85-4655-90ba-8ff84a0482aa \ + --id a1b28c37-2d85-4655-90ba-8ff84a0482bb \ + --id f3e45a12-2d85-4655-90ba-8ff84a0482cc + +# Cancel executions for cleanup (multiple IDs from execution list) +slcli function cancel \ + --id 6d958d07-2d85-4655-90ba-8ff84a0482aa \ + --id a1b28c37-2d85-4655-90ba-8ff84a0482bb +``` + +#### Retry failed executions + +```bash +# Retry a single failed execution (creates new execution with same parameters) +slcli function retry --id 6d958d07-2d85-4655-90ba-8ff84a0482aa + +# Retry multiple failed executions (bulk retry operation) +slcli function retry \ + --id 6d958d07-2d85-4655-90ba-8ff84a0482aa \ + --id a1b28c37-2d85-4655-90ba-8ff84a0482bb + +# Retry executions after fixing system issues +slcli function retry \ + --id 6d958d07-2d85-4655-90ba-8ff84a0482aa \ + --id a1b28c37-2d85-4655-90ba-8ff84a0482bb \ + --id f3e45a12-2d85-4655-90ba-8ff84a0482cc +``` + +### Configuration + +#### Using .env file for local development + +Create a `.env` file in your working directory to configure service URLs for local development: + +```bash +# Function Service URL (for function definition management) +FUNCTION_SERVICE_URL=http://localhost:3000 + +# Function Execution Service URL (for function execution management) +FUNCTION_EXECUTION_SERVICE_URL=http://localhost:3001 + +# Common API settings +SYSTEMLINK_API_KEY=your_api_key_here +SLCLI_SSL_VERIFY=false +``` + +The CLI will automatically load these environment variables from the `.env` file when running commands. You can also set these as regular environment variables in your shell if preferred. + ## Notebook Management The `notebook` command group allows you to manage Jupyter notebooks in SystemLink. diff --git a/pyproject.toml b/pyproject.toml index 832ca51..b3bfeae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ authors = ["Fred Visser "] slcli = "slcli.__main__:cli" build-pyinstaller = "scripts.build_pyinstaller:main" update-version = "scripts.update_version:main" +lint = "scripts.lint:main" [tool.poetry.dependencies] python = ">=3.11.1,<3.14" diff --git a/scripts/lint.py b/scripts/lint.py new file mode 100644 index 0000000..7bdb911 --- /dev/null +++ b/scripts/lint.py @@ -0,0 +1,57 @@ +"""Convenience script to run style linting and formatting. + +Default (no flags): + - Run style guide lint checks (non-modifying) + - Run black in check mode (no file changes) + +With --fix: + - Run style guide lint checks (still non-modifying; separate fixer not provided) + - Run black to auto-format the codebase + +Equivalent commands: + Check: ni-python-styleguide lint && black --check . + Fix: ni-python-styleguide lint && black . + +Exposed as `poetry run lint` via pyproject.toml. +""" + +from __future__ import annotations + +import subprocess +import sys +from typing import List + + +def _run(cmd: List[str]) -> int: + """Run a subprocess command and return its exit code.""" + proc = subprocess.run(cmd, stdout=sys.stdout, stderr=sys.stderr) # noqa: S603,S607 + return proc.returncode + + +def main() -> None: + """Execute lint steps; optionally format when --fix provided.""" + fix = "--fix" in sys.argv[1:] + # Remove our flag so tools don't see it + if fix: + sys.argv = [sys.argv[0]] + [a for a in sys.argv[1:] if a != "--fix"] + + code = 0 + # Lint (non-modifying) + if _run([sys.executable, "-m", "ni_python_styleguide", "lint"]) != 0: + code = 1 + + # Formatting + black_cmd = [sys.executable, "-m", "black"] + if fix: + black_cmd.append(".") + else: + black_cmd.extend(["--check", "."]) + + if _run(black_cmd) != 0: + code = 1 + + sys.exit(code) + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/slcli/config.py b/slcli/config.py new file mode 100644 index 0000000..08c1d54 --- /dev/null +++ b/slcli/config.py @@ -0,0 +1,81 @@ +"""Configuration management for slcli.""" + +import json +import os +from pathlib import Path +from typing import Dict, Optional + + +def get_config_file_path() -> Path: + """Get the path to the slcli configuration file.""" + # Use XDG_CONFIG_HOME if set, otherwise use ~/.config + if "XDG_CONFIG_HOME" in os.environ: + config_dir = Path(os.environ["XDG_CONFIG_HOME"]) / "slcli" + else: + config_dir = Path.home() / ".config" / "slcli" + + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / "config.json" + + +def load_config() -> Dict[str, str]: + """Load configuration from the config file. + + Returns: + Dictionary containing configuration values + """ + config_file = get_config_file_path() + + if not config_file.exists(): + return {} + + try: + with open(config_file, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + # If config file is corrupted or unreadable, return empty config + return {} + + +def save_config(config: Dict[str, str]) -> None: + """Save configuration to the config file. + + Args: + config: Dictionary containing configuration values to save + """ + config_file = get_config_file_path() + + try: + with open(config_file, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + except OSError as e: + raise RuntimeError(f"Failed to save configuration: {e}") + + +def get_function_service_url() -> Optional[str]: + """Get the configured URL for the Function Service. + + Returns: + The configured function service URL, or None if not configured + """ + config = load_config() + return config.get("function_service_url") + + +def set_function_service_url(url: str) -> None: + """Set the URL for the Function Service. + + Args: + url: The URL to use for function commands + """ + config = load_config() + config["function_service_url"] = url + save_config(config) + + +def remove_function_service_url() -> None: + """Remove the configured Function Service URL.""" + config = load_config() + if "function_service_url" in config: + del config["function_service_url"] + save_config(config) diff --git a/slcli/function_click.py b/slcli/function_click.py new file mode 100644 index 0000000..7acd08a --- /dev/null +++ b/slcli/function_click.py @@ -0,0 +1,1375 @@ +"""CLI commands for managing SystemLink WebAssembly function definitions and executions.""" + +import json +import os +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import click +import requests + +from .cli_utils import validate_output_format +from .function_templates import ( + download_and_extract_template, + TEMPLATE_REPO, + TEMPLATE_BRANCH, + TEMPLATE_SUBFOLDERS, +) +from .universal_handlers import UniversalResponseHandler, FilteredResponse +from .utils import ( + display_api_errors, + ExitCodes, + get_base_url, + get_headers, + get_ssl_verify, + get_workspace_id_with_fallback, + get_workspace_map, + handle_api_error, + load_json_file, + make_api_request, +) +from .workspace_utils import get_workspace_display_name, resolve_workspace_filter + + +def load_env_file() -> Dict[str, str]: + """Load environment variables from a .env file in the current directory. + + Returns: + Dictionary of environment variables from .env file + """ + env_vars = {} + env_file = Path.cwd() / ".env" + + if env_file.exists(): + try: + with open(env_file, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + env_vars[key.strip()] = value.strip().strip('"').strip("'") + except Exception: + # Silently ignore .env file parsing errors + pass + + return env_vars + + +def get_function_service_base_url() -> str: + """Get the unified base URL for Function Management Service (v2). + + The unified service consolidates function definition and execution. + + Returns: + Base URL (prefix) for the unified Function Management Service (without version suffix) + """ + env_vars = load_env_file() + + # Prefer explicit FUNCTION_SERVICE_URL + function_url = env_vars.get("FUNCTION_SERVICE_URL") or os.environ.get("FUNCTION_SERVICE_URL") + if function_url: + # Normalize to include /nifunction + return ( + function_url if function_url.endswith("/nifunction") else f"{function_url}/nifunction" + ) + + # Fallback to global SYSTEMLINK_API_URL (handled by get_base_url) + base_url = get_base_url() + return f"{base_url}/nifunction" + + +def get_unified_v2_base() -> str: + """Get the versioned root for unified Function Management Service (v2).""" + return f"{get_function_service_base_url()}/v2" + + +def _query_all_functions( + workspace_filter: Optional[str] = None, + name_filter: Optional[str] = None, + interface_filter: Optional[str] = None, + custom_filter: Optional[str] = None, + workspace_map: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, Any]]: + """Query all function definitions using continuation token pagination. + + Args: + workspace_filter: Optional workspace ID to filter by + name_filter: Optional name pattern to filter by + interface_filter: Optional text to search for in the interface property + custom_filter: Optional custom Dynamic LINQ filter expression + workspace_map: Optional workspace mapping to avoid repeated lookups + + Returns: + List of all function definitions matching the filters + """ + url = f"{get_unified_v2_base()}/query-functions" + all_functions = [] + continuation_token = None + + while True: + # Build payload for the request + payload: Dict[str, Union[int, str, List[str]]] = { + "take": 100, # Use smaller page size for efficient pagination + } + + # Build filter expression + filter_parts = [] + + if workspace_filter: + filter_parts.append(f'workspaceId == "{workspace_filter}"') + + if name_filter: + filter_parts.append(f'name.StartsWith("{name_filter}")') + + if interface_filter: + filter_parts.append(f'interface.Contains("{interface_filter}")') + + # Always filter for WASM runtime by checking for interface.entrypoint since CLI is WASM-only + # Functions with interface.entrypoint are WASM functions + filter_parts.append('interface.entrypoint != null && interface.entrypoint != ""') + + # Add custom filter if provided (this will override automatic filters if both are used) + if custom_filter: + if filter_parts: + # Combine automatic filters with custom filter using AND + combined_filter = f'({" && ".join(filter_parts)}) && ({custom_filter})' + payload["filter"] = combined_filter + else: + payload["filter"] = custom_filter + elif filter_parts: + payload["filter"] = " && ".join(filter_parts) + + # Add continuation token if we have one + if continuation_token: + payload["continuationToken"] = continuation_token + + resp = make_api_request("POST", url, payload) + data = resp.json() + + # Extract functions from this page + functions = data.get("functions", []) + all_functions.extend(functions) + + # Check if there are more pages + continuation_token = data.get("continuationToken") + if not continuation_token: + break + + return all_functions + + +def _query_all_executions( + workspace_filter: Optional[str] = None, + status_filter: Optional[str] = None, + function_id_filter: Optional[str] = None, + workspace_map: Optional[Dict[str, Any]] = None, +) -> List[Dict[str, Any]]: + """Query all function executions using continuation token pagination. + + Args: + workspace_filter: Optional workspace ID to filter by + status_filter: Optional execution status to filter by + function_id_filter: Optional function ID to filter by + workspace_map: Optional workspace mapping to avoid repeated lookups + + Returns: + List of all function executions matching the filters + """ + url = f"{get_unified_v2_base()}/query-executions" + all_executions = [] + continuation_token = None + + while True: + # Build payload for the request + payload: Dict[str, Union[int, str, List[str]]] = { + "take": 100, # Use smaller page size for efficient pagination + } + + # Build filter expression + filter_parts = [] + + if workspace_filter: + filter_parts.append(f'workspaceId == "{workspace_filter}"') + + if status_filter: + filter_parts.append(f'status == "{status_filter}"') + + if function_id_filter: + filter_parts.append(f'functionId == "{function_id_filter}"') + + if filter_parts: + payload["filter"] = " && ".join(filter_parts) + + # Add continuation token if we have one + if continuation_token: + payload["continuationToken"] = continuation_token + + resp = make_api_request("POST", url, payload) + data = resp.json() + + # Extract executions from this page + executions = data.get("executions", []) + all_executions.extend(executions) + + # Check if there are more pages + continuation_token = data.get("continuationToken") + if not continuation_token: + break + + return all_executions + + +def register_function_commands(cli: Any) -> None: + """Register the 'function' command group and its subcommands.""" + + @cli.group() + def function(): + """Manage function definitions and executions.""" + + pass + + # ------------------------------------------------------------------ + # Initialization (template bootstrap) command + # ------------------------------------------------------------------ + + @function.command(name="init") + @click.option( + "--language", + "-l", + type=click.Choice(["typescript", "python", "ts", "py"], case_sensitive=False), + help="Template language (typescript|python). Will prompt if omitted.", + ) + @click.option( + "--directory", + "-d", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + help="Target directory to create or populate (defaults to current working directory)", + ) + @click.option( + "--force", + is_flag=True, + help="Overwrite existing non-empty directory contents.", + ) + def init_function_template( + language: Optional[str], directory: Optional[Path], force: bool + ) -> None: + """Initialize a local function template (TypeScript Hono or Python HTTP).""" + try: + # Prompt for language if not supplied + if not language: + language = click.prompt( + "Select language", + type=click.Choice(["typescript", "python"]), # type: ignore[arg-type] + ) + if not language: + click.echo("✗ Language not specified.", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + language_norm = language.lower() + if language_norm in {"ts"}: + language_norm = "typescript" + if language_norm in {"py"}: + language_norm = "python" + if language_norm not in {"typescript", "python"}: + click.echo("✗ Unsupported language.", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + # Prompt for directory if not supplied + if directory is None: + dir_input = click.prompt( + "Target directory (leave blank for current directory)", + default="", + show_default=False, + ) + if dir_input.strip(): + directory = Path(dir_input.strip()) + + target_dir = directory or Path.cwd() + if not target_dir.exists(): + target_dir.mkdir(parents=True, exist_ok=True) + else: + # If directory is not empty and no force, abort + if any(target_dir.iterdir()) and not force: + click.echo( + "✗ Target directory is not empty. Use --force to initialize anyway.", + err=True, + ) + sys.exit(ExitCodes.INVALID_INPUT) + + repo = TEMPLATE_REPO + branch = TEMPLATE_BRANCH + subfolder = TEMPLATE_SUBFOLDERS[language_norm] + click.echo(f"Downloading {language_norm} template from {repo}@{branch}:{subfolder} ...") + download_and_extract_template(language_norm, target_dir) + click.echo("✓ Template files created.") + + # Print next steps (no automatic install/build) + click.echo("\nNext steps:") + rel = target_dir.resolve() + if language_norm == "typescript": + click.echo(f" 1. cd {rel}") + click.echo(" 2. npm install") + click.echo(" 3. npm run build") + click.echo( + " 4. Use 'slcli function manage create' to register your compiled dist/main.wasm" + ) + else: + click.echo(f" 1. cd {rel}") + click.echo(" 2. (Optional) python -m venv .venv && source .venv/bin/activate") + click.echo(" 3. pip install -r requirements.txt (if provided)") + click.echo( + " 4. Use 'slcli function manage create' to register your function per README" + ) + sys.exit(ExitCodes.SUCCESS) + except SystemExit: # re-raise explicit exits + raise + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + # Function Execution Commands Group + @function.group(name="execute") + def execute_group(): + """Execute and manage function executions.""" + pass + + # Function Management Commands Group + @function.group(name="manage") + def manage_group(): + """Manage function definitions.""" + pass + + @manage_group.command(name="list") + @click.option( + "--workspace", + "-w", + help="Filter by workspace name or ID", + ) + @click.option( + "--name", + "-n", + help="Filter by function name (starts with pattern)", + ) + @click.option( + "--interface-contains", + help="Filter by interface content (searches interface property for text)", + ) + @click.option( + "--filter", + help='Custom Dynamic LINQ filter expression for advanced filtering. Examples: name.StartsWith("data") && interface.Contains("entrypoint")', + ) + @click.option( + "--take", + "-t", + type=int, + default=25, + show_default=True, + help="Maximum number of functions to return", + ) + @click.option( + "--format", + "-f", + type=click.Choice(["table", "json"]), + default="table", + show_default=True, + help="Output format", + ) + def list_functions( + workspace: Optional[str] = None, + name: Optional[str] = None, + interface_contains: Optional[str] = None, + filter: Optional[str] = None, + take: int = 25, + format: str = "table", + ) -> None: + """List function definitions.""" + format_output = validate_output_format(format) + + try: + workspace_map = get_workspace_map() + + # Resolve workspace filter to ID if specified + workspace_id = None + if workspace: + workspace_id = resolve_workspace_filter(workspace, workspace_map) + + # Use continuation token pagination to get all functions + all_functions = _query_all_functions( + workspace_filter=workspace_id, + name_filter=name, + interface_filter=interface_contains, + custom_filter=filter, + workspace_map=workspace_map, + ) + + # Create a mock response with all data + resp: Any = FilteredResponse({"functions": all_functions}) + + # Use universal response handler with function formatter + def function_formatter(function: Dict[str, Any]) -> List[str]: + ws_guid = function.get("workspaceId", "") + ws_name = get_workspace_display_name(ws_guid, workspace_map) + + # Format timestamps + created_at = function.get("createdAt", "") + if created_at: + created_at = created_at.split("T")[0] # Just the date part + + return [ + function.get("id", ""), + function.get("name", ""), + function.get("version", ""), + ws_name, + created_at, + ] + + UniversalResponseHandler.handle_list_response( + resp=resp, + data_key="functions", + item_name="function", + format_output=format_output, + formatter_func=function_formatter, + headers=[ + "ID", + "Name", + "Version", + "Workspace", + "Created", + ], + column_widths=[36, 30, 10, 20, 12], + empty_message="No function definitions found.", + enable_pagination=True, + page_size=take, + ) + + except Exception as exc: + handle_api_error(exc) + + @manage_group.command(name="get") + @click.option( + "--id", + "-i", + "function_id", + required=True, + help="Function ID to retrieve", + ) + @click.option( + "--format", + "-f", + type=click.Choice(["table", "json"]), + default="table", + show_default=True, + help="Output format", + ) + def get_function(function_id: str, format: str = "table") -> None: + """Get detailed information about a specific function definition.""" + format_output = validate_output_format(format) + url = f"{get_unified_v2_base()}/functions/{function_id}" + + try: + resp = make_api_request("GET", url) + data = resp.json() + + if format_output == "json": + click.echo(json.dumps(data, indent=2)) + return + + workspace_map = get_workspace_map() + ws_name = get_workspace_display_name(data.get("workspaceId", ""), workspace_map) + + click.echo("Function Definition Details:") + click.echo("=" * 50) + click.echo(f"ID: {data.get('id', 'N/A')}") + click.echo(f"Name: {data.get('name', 'N/A')}") + click.echo(f"Description: {data.get('description', 'N/A')}") + click.echo(f"Workspace: {ws_name}") + click.echo(f"Version: {data.get('version', 'N/A')}") + click.echo(f"Runtime: {data.get('runtime', 'N/A')}") + click.echo(f"Created At: {data.get('createdAt', 'N/A')}") + click.echo(f"Updated At: {data.get('updatedAt', 'N/A')}") + + interface = data.get("interface") + if interface: + # New-style interface (HTTP-like) with endpoints summary + endpoints = interface.get("endpoints") + if endpoints and isinstance(endpoints, list): + click.echo("\nInterface:") + default_path = interface.get("defaultPath") + if default_path: + click.echo(f"Default Path: {default_path}") + click.echo("Endpoints:") + for ep in endpoints: + methods = ( + ",".join(ep.get("methods", [])).upper() if ep.get("methods") else "*" + ) + path = ep.get("path", "") + desc = ep.get("description", "") + click.echo(f" - {methods} {path} - {desc}") + # Legacy-style interface fields + if interface.get("entrypoint"): + click.echo(f"Entrypoint: {interface['entrypoint']}") + if interface.get("parameters"): + click.echo("\nParameters Schema:") + click.echo(json.dumps(interface["parameters"], indent=2)) + if interface.get("returns"): + click.echo("\nReturns Schema:") + click.echo(json.dumps(interface["returns"], indent=2)) + else: + if data.get("entrypoint"): + click.echo(f"Entrypoint: {data['entrypoint']}") + if data.get("parameters"): + click.echo("\nParameters Schema:") + click.echo(json.dumps(data["parameters"], indent=2)) + if data.get("returns"): + click.echo("\nReturns Schema:") + click.echo(json.dumps(data["returns"], indent=2)) + + if data.get("properties"): + click.echo("\nCustom Properties:") + for key, value in data["properties"].items(): + click.echo(f" {key}: {value}") + except Exception as exc: + handle_api_error(exc) + + @manage_group.command(name="create") + @click.option( + "--name", + "-n", + required=True, + help="Function display name", + ) + @click.option( + "--workspace", + "-w", + default="Default", + help="Workspace name or ID (default: 'Default')", + ) + @click.option( + "--runtime", + "-r", + default="wasm", + type=click.Choice(["wasm"], case_sensitive=False), + help="Runtime environment for the function (WebAssembly)", + ) + @click.option( + "--description", + "-d", + help="Function description", + ) + @click.option( + "--version", + "-v", + default="1.0.0", + show_default=True, + help="Function version", + ) + @click.option( + "--entrypoint", + "-e", + help="WASM file name without extension (stored in interface.entrypoint)", + ) + @click.option( + "--content", + "-c", + help="Function source code content or file path", + ) + @click.option( + "--parameters-schema", + "-p", + help="JSON schema for function parameters (stored in interface.parameters) (JSON string or file path)", + ) + @click.option( + "--returns-schema", + help="JSON schema for function return value (stored in interface.returns) (JSON string or file path)", + ) + @click.option( + "--properties", + help='Custom properties as JSON string for metadata and filtering (e.g., \'{"category": "processing", "team": "data-science"}\')', + ) + def create_function( + name: str, + workspace: str = "Default", + runtime: str = "wasm", + description: Optional[str] = None, + version: str = "1.0.0", + entrypoint: Optional[str] = None, + content: Optional[str] = None, + parameters_schema: Optional[str] = None, + returns_schema: Optional[str] = None, + properties: Optional[str] = None, + ) -> None: + """Create a new function definition with metadata for efficient querying.""" + url = f"{get_unified_v2_base()}/functions" + try: + workspace_id = get_workspace_id_with_fallback(workspace) + + custom_properties: Dict[str, Any] = {} + if properties: + try: + custom_properties.update(json.loads(properties)) + except json.JSONDecodeError: + click.echo("✗ Error: Invalid JSON in --properties option", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + params_schema = None + if parameters_schema: + try: + params_schema = ( + json.loads(parameters_schema) + if parameters_schema.startswith("{") + else load_json_file(parameters_schema) + ) + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error loading parameters schema: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + ret_schema = None + if returns_schema: + try: + ret_schema = ( + json.loads(returns_schema) + if returns_schema.startswith("{") + else load_json_file(returns_schema) + ) + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error loading returns schema: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + if not entrypoint and content and Path(content).exists(): + entrypoint = Path(content).stem + + interface_obj: Optional[Dict[str, Any]] = None + if entrypoint or params_schema or ret_schema: + interface_obj = {} + if entrypoint: + interface_obj["entrypoint"] = entrypoint + if params_schema: + interface_obj["parameters"] = params_schema + if ret_schema: + interface_obj["returns"] = ret_schema + + if content: + if Path(content).exists(): + try: + with open(content, "rb") as f: + content_data = f.read() + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error reading content file: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + else: + content_data = content.encode("utf-8") + + function_metadata: Dict[str, Any] = { + "name": name, + "workspaceId": workspace_id, + "runtime": runtime.lower(), + "version": version, + } + if description: + function_metadata["description"] = description + if interface_obj: + function_metadata["interface"] = interface_obj + if custom_properties: + function_metadata["properties"] = custom_properties + + files = { + "metadata": (None, json.dumps(function_metadata), "application/json"), + "content": ("function_content", content_data, "application/octet-stream"), + } + resp = requests.post( + url, + files=files, + headers=get_headers(""), + verify=get_ssl_verify(), + ) + resp.raise_for_status() + else: + function_request: Dict[str, Any] = { + "name": name, + "workspaceId": workspace_id, + "runtime": runtime.lower(), + "version": version, + } + if description: + function_request["description"] = description + if interface_obj: + function_request["interface"] = interface_obj + if custom_properties: + function_request["properties"] = custom_properties + resp = make_api_request("POST", url, function_request) + + response_data = resp.json() + click.echo( + f"✓ Function definition created successfully with ID: {response_data.get('id', '')}" + ) + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + @manage_group.command(name="update") + @click.option( + "--id", + "-i", + "function_id", + required=True, + help="Function ID to update", + ) + @click.option( + "--name", + "-n", + help="Updated function display name", + ) + @click.option( + "--description", + "-d", + help="Updated function description", + ) + @click.option( + "--version", + "-v", + help="Updated function version", + ) + @click.option( + "--workspace", + "-w", + help="Updated workspace for the function (name or ID)", + ) + @click.option( + "--runtime", + help="Updated runtime environment (default: wasm)", + default="wasm", + ) + @click.option( + "--entrypoint", + "-e", + help="Updated WASM file name without extension (stored in interface.entrypoint)", + ) + @click.option( + "--content", + "-c", + help="Updated function source code content or file path", + ) + @click.option( + "--parameters-schema", + "-p", + help="Updated JSON schema for function parameters (stored in interface.parameters) (JSON string or file path)", + ) + @click.option( + "--returns-schema", + help="Updated JSON schema for function return value (stored in interface.returns) (JSON string or file path)", + ) + @click.option( + "--properties", + help="Updated custom properties as JSON string for metadata and filtering (replaces existing properties)", + ) + def update_function( + function_id: str, + name: Optional[str] = None, + description: Optional[str] = None, + version: Optional[str] = None, + workspace: Optional[str] = None, + runtime: str = "wasm", + entrypoint: Optional[str] = None, + content: Optional[str] = None, + parameters_schema: Optional[str] = None, + returns_schema: Optional[str] = None, + properties: Optional[str] = None, + ) -> None: + """Update an existing function definition.""" + url = f"{get_unified_v2_base()}/functions/{function_id}" + try: + existing_function = make_api_request("GET", url).json() + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error fetching existing function: {e}", err=True) + sys.exit(ExitCodes.NOT_FOUND) + + try: + workspace_id = existing_function.get("workspaceId") + if workspace: + try: + workspace_id = get_workspace_id_with_fallback(workspace) + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error resolving workspace '{workspace}': {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + params_schema = None + if parameters_schema: + try: + params_schema = ( + json.loads(parameters_schema) + if parameters_schema.startswith("{") + else load_json_file(parameters_schema) + ) + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error loading parameters schema: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + ret_schema = None + if returns_schema: + try: + ret_schema = ( + json.loads(returns_schema) + if returns_schema.startswith("{") + else load_json_file(returns_schema) + ) + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error loading returns schema: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + interface_obj = ( + existing_function.get("interface", {}).copy() + if existing_function.get("interface") + else {} + ) + if entrypoint is not None: + interface_obj["entrypoint"] = entrypoint + if params_schema is not None: + interface_obj["parameters"] = params_schema + if ret_schema is not None: + interface_obj["returns"] = ret_schema + + custom_properties = None + if properties: + try: + custom_properties = json.loads(properties) + except json.JSONDecodeError: + click.echo("✗ Error: Invalid JSON in --properties option", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + if ( + name is None + and description is None + and version is None + and workspace is None + and entrypoint is None + and content is None + and parameters_schema is None + and returns_schema is None + and properties is None + ): + click.echo( + "✗ No updates provided. Please specify at least one field to update.", err=True + ) + sys.exit(ExitCodes.INVALID_INPUT) + + function_metadata: Dict[str, Any] = { + "name": name if name is not None else existing_function["name"], + "workspaceId": workspace_id, + "runtime": runtime, + } + if description is not None: + function_metadata["description"] = description + elif existing_function.get("description") is not None: + function_metadata["description"] = existing_function["description"] + if version is not None: + function_metadata["version"] = version + elif existing_function.get("version"): + function_metadata["version"] = existing_function["version"] + if interface_obj: + function_metadata["interface"] = interface_obj + if custom_properties is not None: + function_metadata["properties"] = custom_properties + elif existing_function.get("properties"): + function_metadata["properties"] = existing_function["properties"] + + content_data = None + if content: + if Path(content).exists(): + try: + with open(content, "rb") as f: + content_data = f.read() + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error reading content file: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + else: + content_data = content.encode("utf-8") + + files: Dict[str, Any] = { + "metadata": (None, json.dumps(function_metadata), "application/json"), + } + if content_data is not None: + files["content"] = ("function_content", content_data, "application/octet-stream") + + resp = requests.put( + url, + files=files, + headers=get_headers(""), + verify=get_ssl_verify(), + ) + resp.raise_for_status() + click.echo("✓ Function definition updated successfully") + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + @manage_group.command(name="delete") + @click.option( + "--id", + "-i", + "function_id", + required=True, + help="Function ID to delete", + ) + @click.option( + "--force", + is_flag=True, + help="Skip confirmation prompt", + ) + def delete_function(function_id: str, force: bool = False) -> None: + """Delete a function definition.""" + url = f"{get_unified_v2_base()}/functions/{function_id}" + try: + if not force and not click.confirm( + f"Are you sure you want to delete function {function_id}?" + ): + click.echo("Function deletion cancelled.") + return + resp = make_api_request("DELETE", url, handle_errors=False) + if resp.status_code == 204: + click.echo(f"✓ Function {function_id} deleted successfully.") + else: + response_data = resp.json() if resp.text.strip() else {} + display_api_errors("Function deletion failed", response_data, detailed=True) + sys.exit(ExitCodes.GENERAL_ERROR) + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + @manage_group.command(name="download-content") + @click.option( + "--id", + "-i", + "function_id", + required=True, + help="Function ID to download content from", + ) + @click.option( + "--output", + "-o", + help="Output file path (defaults to function_ with appropriate extension)", + ) + def download_function_content(function_id: str, output: Optional[str] = None) -> None: + """Download function source code content.""" + url = f"{get_unified_v2_base()}/functions/{function_id}/content" + try: + resp = make_api_request("GET", url, handle_errors=False) + if resp.status_code != 200: + response_data = resp.json() if resp.text.strip() else {} + display_api_errors("Function content download failed", response_data, detailed=True) + sys.exit(ExitCodes.GENERAL_ERROR) + + if not output: + try: + meta_data = make_api_request( + "GET", f"{get_unified_v2_base()}/functions/{function_id}" + ).json() + runtime = meta_data.get("runtime", "").lower() + ext = {"wasm": ".wasm"}.get(runtime, ".wasm") + output = f"function_{function_id}{ext}" + except Exception: # noqa: BLE001 + output = f"function_{function_id}.wasm" + + with open(output, "wb") as f: + f.write(resp.content) + click.echo(f"✓ Function content downloaded to '{output}' ({len(resp.content)} bytes)") + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + # Function Execution Management Commands + @execute_group.command(name="list") + @click.option( + "--workspace", + "-w", + help="Filter by workspace name or ID", + ) + @click.option( + "--status", + "-s", + type=click.Choice( + ["QUEUED", "RUNNING", "SUCCEEDED", "FAILED", "CANCELLED", "TIMEOUT"], + case_sensitive=False, + ), + help="Filter by execution status", + ) + @click.option( + "--function-id", + "-f", + help="Filter by function ID", + ) + @click.option( + "--take", + "-t", + type=int, + default=25, + show_default=True, + help="Maximum number of executions to return", + ) + @click.option( + "--format", + "-fmt", + type=click.Choice(["table", "json"]), + default="table", + show_default=True, + help="Output format", + ) + def list_executions( + workspace: Optional[str] = None, + status: Optional[str] = None, + function_id: Optional[str] = None, + take: int = 25, + format: str = "table", + ) -> None: + """List function executions.""" + format_output = validate_output_format(format) + + try: + workspace_map = get_workspace_map() + + # Resolve workspace filter to ID if specified + workspace_id = None + if workspace: + workspace_id = resolve_workspace_filter(workspace, workspace_map) + + # Normalize status to uppercase if provided + status_filter = status.upper() if status else None + + # Use continuation token pagination to get all executions + all_executions = _query_all_executions( + workspace_id, status_filter, function_id, workspace_map + ) + + # Create a mock response with all data + resp: Any = FilteredResponse({"executions": all_executions}) + + # Use universal response handler with execution formatter + def execution_formatter(execution: Dict[str, Any]) -> List[str]: + ws_guid = execution.get("workspaceId", "") + ws_name = get_workspace_display_name(ws_guid, workspace_map) + + # Format timestamps + queued_at = execution.get("queuedAt", "") + if queued_at: + queued_at = queued_at.split("T")[0] # Just the date part + + return [ + execution.get("id", ""), # Full ID + execution.get("functionId", ""), # Full function ID + ws_name, + execution.get("status", "UNKNOWN"), + queued_at, + ] + + UniversalResponseHandler.handle_list_response( + resp=resp, + data_key="executions", + item_name="execution", + format_output=format_output, + formatter_func=execution_formatter, + headers=["ID", "Function ID", "Workspace", "Status", "Queued"], + column_widths=[36, 36, 20, 12, 12], + empty_message="No function executions found.", + enable_pagination=True, + page_size=take, + ) + + except Exception as exc: + handle_api_error(exc) + + @execute_group.command(name="get") + @click.option( + "--id", + "-i", + "execution_id", + required=True, + help="Execution ID to retrieve", + ) + @click.option( + "--format", + "-fmt", + type=click.Choice(["table", "json"]), + default="table", + show_default=True, + help="Output format", + ) + def get_execution(execution_id: str, format: str = "table") -> None: + """Get detailed information about a specific function execution.""" + format_output = validate_output_format(format) + url = f"{get_unified_v2_base()}/executions/{execution_id}" + try: + data = make_api_request("GET", url).json() + if format_output == "json": + click.echo(json.dumps(data, indent=2)) + return + workspace_map = get_workspace_map() + ws_name = get_workspace_display_name(data.get("workspaceId", ""), workspace_map) + click.echo("Function Execution Details:") + click.echo("=" * 50) + click.echo(f"ID: {data.get('id', 'N/A')}") + click.echo(f"Function ID: {data.get('functionId', 'N/A')}") + click.echo(f"Workspace: {ws_name}") + click.echo(f"Status: {data.get('status', 'N/A')}") + click.echo(f"Timeout: {data.get('timeout', 'N/A')} seconds") + click.echo(f"Retry Count: {data.get('retryCount', 0)}") + click.echo(f"Cached Result: {data.get('cachedResult', False)}") + click.echo(f"Queued At: {data.get('queuedAt', 'N/A')}") + click.echo(f"Started At: {data.get('startedAt', 'N/A')}") + click.echo(f"Completed At: {data.get('completedAt', 'N/A')}") + if data.get("parameters"): + click.echo("\nParameters:") + click.echo(json.dumps(data["parameters"], indent=2)) + if data.get("result"): + click.echo("\nResult:") + click.echo(json.dumps(data["result"], indent=2)) + if data.get("errorMessage"): + click.echo("\nError Message:") + click.echo(data["errorMessage"]) + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + @execute_group.command(name="sync") + @click.option( + "--function-id", + "-f", + required=True, + help="Function ID to execute synchronously", + ) + @click.option( + "--workspace", + "-w", + default="Default", + help="Workspace name or ID (default: 'Default')", + ) + @click.option( + "--parameters", + "-p", + help="Raw JSON (string or file) for advanced parameters object (overrides --method/--path/--header/--body).", + ) + @click.option( + "--method", + default="POST", + show_default=True, + help="Invocation HTTP method placed in parameters.method (ignored if --parameters used).", + ) + @click.option( + "--path", + default="/invoke", + show_default=True, + help="Invocation path placed in parameters.path (ignored if --parameters used).", + ) + @click.option( + "--header", + "-H", + multiple=True, + help="Request header key=value (can repeat). Ignored if --parameters used.", + ) + @click.option( + "--body", + help="JSON string or file for request body placed in parameters.body (ignored if --parameters used).", + ) + @click.option( + "--timeout", + "-t", + type=int, + default=300, + show_default=True, + help="Execution timeout in seconds (0 for infinite, maximum 3600 for synchronous execution)", + ) + @click.option( + "--client-request-id", + help="Client-provided unique identifier for tracking", + ) + @click.option( + "--format", + "-fmt", + type=click.Choice(["table", "json"]), + default="table", + show_default=True, + help="Output format", + ) + def execute_function( + function_id: str, + workspace: str = "Default", + parameters: Optional[str] = None, + method: str = "POST", + path: str = "/invoke", + header: Optional[tuple] = None, + body: Optional[str] = None, + timeout: int = 300, + client_request_id: Optional[str] = None, + format: str = "table", + ) -> None: + """Execute a function synchronously and return the result. + + This sends a single request and waits for completion (no async polling). + """ + format_output = validate_output_format(format) + url = f"{get_unified_v2_base()}/functions/{function_id}/execute" + try: + execution_parameters: Dict[str, Any] = {} + if parameters: + try: + execution_parameters = ( + json.loads(parameters) + if parameters.strip().startswith("{") + else load_json_file(parameters) + ) + except Exception as e: # noqa: BLE001 + click.echo(f"✗ Error parsing parameters: {e}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + legacy_keys = {"method", "path", "headers", "body"} + if not any(k in execution_parameters for k in legacy_keys): + execution_parameters = {"body": execution_parameters} + else: + # Determine if user explicitly set any of the four HTTP-related flags. + # We treat them as specified only if they differ from defaults or were + # provided via the parameters option. + user_provided_any = ( + bool(header) + or body is not None + or (method.upper() != "POST" or path != "/invoke") + ) + if not user_provided_any: + # Pure omission: apply fallback default GET / + execution_parameters = {"method": "GET", "path": "/"} + else: + headers_dict: Dict[str, str] = {} + if header: + for h in header: + if "=" not in h: + click.echo( + f"✗ Invalid header format (expected key=value): {h}", + err=True, + ) + sys.exit(ExitCodes.INVALID_INPUT) + k, v = h.split("=", 1) + headers_dict[k.strip()] = v.strip() + body_value: Any = None + if body: + try: + body_value = ( + json.loads(body) + if body.strip().startswith("{") or body.strip().startswith("[") + else load_json_file(body) + ) + except Exception: + body_value = body + norm_path = path if path.startswith("/") else f"/{path}" + execution_parameters = { + "method": method.upper(), + "path": norm_path, + } + if headers_dict: + execution_parameters["headers"] = headers_dict + if body_value is not None: + execution_parameters["body"] = body_value + if timeout > 3600: + click.echo( + "✗ Timeout cannot exceed 3600 seconds (1 hour) for synchronous execution", + err=True, + ) + sys.exit(ExitCodes.INVALID_INPUT) + execute_request: Dict[str, Any] = { + "parameters": execution_parameters, + "timeout": timeout, + "async": False, + } + if client_request_id: + execute_request["clientRequestId"] = client_request_id + response_data = make_api_request("POST", url, execute_request).json() + if format_output == "json": + click.echo(json.dumps(response_data, indent=2)) + return + click.echo("Function Execution Completed:") + click.echo("=" * 50) + click.echo(f"Execution ID: {response_data.get('executionId', 'N/A')}") + click.echo(f"Execution Time: {response_data.get('executionTime', 0)} ms") + click.echo(f"Cached Result: {response_data.get('cachedResult', False)}") + result = response_data.get("result") + if result is not None: + click.echo("\nResult:") + click.echo(json.dumps(result, indent=2)) + else: + click.echo("\nResult: None (no return value)") + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + @execute_group.command(name="cancel") + @click.option( + "--id", + "-i", + "execution_ids", + multiple=True, + required=True, + help="Execution ID(s) to cancel (can be specified multiple times)", + ) + def cancel_executions(execution_ids: tuple) -> None: + """Cancel one or more function executions.""" + url = f"{get_unified_v2_base()}/executions/cancel" + payload = {"ids": list(execution_ids)} + try: + resp = make_api_request("POST", url, payload, handle_errors=False) + if resp.status_code == 204: + if len(execution_ids) == 1: + click.echo(f"✓ Execution {execution_ids[0]} cancelled successfully.") + else: + click.echo(f"✓ All {len(execution_ids)} executions cancelled successfully.") + return + if resp.status_code == 200: + data = resp.json() + cancelled = data.get("cancelled", []) + failed = data.get("failed", []) + if cancelled: + if len(cancelled) == 1: + click.echo(f"✓ Execution {cancelled[0]} cancelled successfully.") + else: + click.echo(f"✓ {len(cancelled)} executions cancelled successfully:") + for eid in cancelled: + click.echo(f" - {eid}") + if failed: + click.echo(f"✗ Failed to cancel {len(failed)} execution(s):", err=True) + for failure in failed: + eid = failure.get("id", "unknown") + err_msg = failure.get("error", {}).get("message", "Unknown error") + click.echo(f" - {eid}: {err_msg}", err=True) + sys.exit(ExitCodes.GENERAL_ERROR) + return + response_data = resp.json() if resp.text.strip() else {} + display_api_errors( + "Function execution cancellation failed", response_data, detailed=True + ) + sys.exit(ExitCodes.GENERAL_ERROR) + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) + + @execute_group.command(name="retry") + @click.option( + "--id", + "-i", + "execution_ids", + multiple=True, + required=True, + help="Execution ID(s) to retry (can be specified multiple times)", + ) + def retry_executions(execution_ids: tuple) -> None: + """Retry one or more failed function executions.""" + url = f"{get_unified_v2_base()}/executions/retry" + payload = {"ids": list(execution_ids)} + try: + resp = make_api_request("POST", url, payload, handle_errors=False) + if resp.status_code in (200, 201): + data = resp.json() + executions = data.get("executions", []) + failed = data.get("failed", []) + if executions: + click.echo(f"✓ {len(executions)} retry executions created successfully:") + for execution in executions: + click.echo(f" - New execution: {execution.get('id', '')}") + if failed: + click.echo(f"✗ Failed to retry {len(failed)} execution(s):", err=True) + for failure in failed: + eid = failure.get("id", "unknown") + err_msg = failure.get("error", {}).get("message", "Unknown error") + click.echo(f" - {eid}: {err_msg}", err=True) + sys.exit(ExitCodes.GENERAL_ERROR) + return + response_data = resp.json() if resp.text.strip() else {} + display_api_errors("Function execution retry failed", response_data, detailed=True) + sys.exit(ExitCodes.GENERAL_ERROR) + except Exception as exc: # noqa: BLE001 + handle_api_error(exc) diff --git a/slcli/function_templates.py b/slcli/function_templates.py new file mode 100644 index 0000000..3d0aa71 --- /dev/null +++ b/slcli/function_templates.py @@ -0,0 +1,85 @@ +"""Helpers for initializing local function templates (TypeScript / Python). + +This module encapsulates downloading and extracting subfolders from the +SystemLink Enterprise examples repository, adding safety checks against +path traversal, symlinks, and unexpected archive contents. +""" + +from __future__ import annotations + +import io +import sys +import tarfile +from pathlib import Path +from typing import Dict + +import click +import requests + +from .utils import ExitCodes + +TEMPLATE_REPO = "ni/systemlink-enterprise-examples" +TEMPLATE_BRANCH = "function-examples" # Treated as stable per user direction +TEMPLATE_SUBFOLDERS: Dict[str, str] = { + "typescript": "function-examples/typescript-hono-function", + "python": "function-examples/python-http-function", +} + +_DOWNLOAD_TIMEOUT_SECONDS = 60 + + +def download_and_extract_template(language: str, destination: Path) -> None: + """Download and extract the specified language template into destination. + + Args: + language: Normalized language key ('typescript' or 'python'). + destination: Directory to populate (must already exist). + """ + if language not in TEMPLATE_SUBFOLDERS: + click.echo(f"✗ Unsupported template language: {language}", err=True) + sys.exit(ExitCodes.INVALID_INPUT) + + subfolder = TEMPLATE_SUBFOLDERS[language] + tarball_url = f"https://codeload.github.com/{TEMPLATE_REPO}/tar.gz/{TEMPLATE_BRANCH}" + resp = None + try: + resp = requests.get(tarball_url, timeout=_DOWNLOAD_TIMEOUT_SECONDS) + except requests.RequestException as exc: # noqa: BLE001 + click.echo(f"✗ Network error downloading template: {exc}", err=True) + sys.exit(ExitCodes.NETWORK_ERROR) + if resp.status_code != 200: + click.echo( + f"✗ Failed to download template (HTTP {resp.status_code}) from {tarball_url}", + err=True, + ) + sys.exit(ExitCodes.NETWORK_ERROR) + + try: + with tarfile.open(fileobj=io.BytesIO(resp.content), mode="r:gz") as tf: # type: ignore[arg-type] + for member in tf.getmembers(): + # Skip symlinks / hard links for safety + if member.issym() or member.islnk(): # pragma: no cover - defensive + continue + parts = member.name.split("/", 1) + if len(parts) < 2: + continue + remainder = parts[1] + if not remainder.startswith(subfolder.rstrip("/")): + continue + # Compute relative path inside desired subfolder + relative_path = Path(remainder).relative_to(subfolder) + if any(p == ".." for p in relative_path.parts): # Path traversal guard + continue + target_path = destination / relative_path + if member.isdir(): + target_path.mkdir(parents=True, exist_ok=True) + continue + target_path.parent.mkdir(parents=True, exist_ok=True) + extracted = tf.extractfile(member) + if not extracted: + continue + with open(target_path, "wb") as f_out: + f_out.write(extracted.read()) + except Exception as exc: # noqa: BLE001 + click.echo(f"✗ Error extracting template: {exc}", err=True) + sys.exit(ExitCodes.GENERAL_ERROR) diff --git a/slcli/main.py b/slcli/main.py index 784a762..d8f5202 100644 --- a/slcli/main.py +++ b/slcli/main.py @@ -9,6 +9,7 @@ from .completion_click import register_completion_command from .dff_click import register_dff_commands +from .function_click import register_function_commands from .notebook_click import register_notebook_commands from .templates_click import register_templates_commands from .user_click import register_user_commands @@ -56,9 +57,7 @@ def get_ascii_art() -> str: @click.option("--version", "-v", is_flag=True, help="Show version and exit") @click.pass_context def cli(ctx, version): - """ - SystemLink CLI (slcli) - Command-line interface for SystemLink resources. - """ + """SystemLink CLI (slcli) - Command-line interface for SystemLink resources.""" # noqa: D403 if version: click.echo(f"slcli version {get_version()}") ctx.exit() @@ -119,6 +118,7 @@ def logout(): register_completion_command(cli) register_dff_commands(cli) +register_function_commands(cli) register_templates_commands(cli) register_notebook_commands(cli) register_user_commands(cli) diff --git a/tests/unit/test_function_click.py b/tests/unit/test_function_click.py new file mode 100644 index 0000000..496f37d --- /dev/null +++ b/tests/unit/test_function_click.py @@ -0,0 +1,415 @@ +"""Tests for function CLI commands (unified v2 API).""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + +import click +import pytest +from click.testing import CliRunner + +from slcli.function_click import register_function_commands +from .test_utils import patch_keyring + + +def make_cli() -> click.Group: + """Create a minimal CLI registering only function commands for isolated tests. + + Returns: + click.Group: Root CLI group with function commands registered. + """ + + @click.group() + def cli() -> None: + """Root test CLI group.""" + pass + + register_function_commands(cli) + return cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +class _MockResponse: + """Simple mock of requests.Response for JSON interactions.""" + + def __init__(self, data: Dict[str, Any], status_code: int = 200): + self._data = data + self.status_code = status_code + + def json(self) -> Dict[str, Any]: + """Return the JSON data.""" + return self._data + + def raise_for_status(self) -> None: + """Raise an exception if status code indicates an error.""" + if self.status_code >= 400: + raise Exception("HTTP error") + + @property + def text(self) -> str: + """Return the response text as a string.""" + return json.dumps(self._data) if self._data else "" + + +def test_function_list_functions_json(monkeypatch: Any, runner: CliRunner) -> None: + """List functions across multiple pages and verify JSON aggregation.""" + patch_keyring(monkeypatch) + + # Two paginated responses + paged_responses: List[Dict[str, Any]] = [ + { + "functions": [ + { + "id": "func-1", + "name": "Adder", + "version": "1.0.0", + "workspaceId": "ws1", + "createdAt": "2024-01-01T00:00:00Z", + "interface": {"entrypoint": "adder"}, + } + ], + "continuationToken": "next-token", + }, + { + "functions": [ + { + "id": "func-2", + "name": "Multiplier", + "version": "1.0.0", + "workspaceId": "ws1", + "createdAt": "2024-01-02T00:00:00Z", + "interface": {"entrypoint": "mult"}, + } + ] + }, + ] + + def mock_make_api_request( + method: str, url: str, payload=None, headers=None, handle_errors=True + ): + """Mock API request function for testing. + + Returns paginated function list responses or workspace data based on the URL. + Raises AssertionError for unexpected URLs. + """ + if "query-functions" in url: + return _MockResponse(paged_responses.pop(0)) + if url.endswith("/niuser/v1/workspaces?take=1000"): + return _MockResponse({"workspaces": [{"id": "ws1", "name": "Default"}]}) + raise AssertionError(f"Unexpected URL {url}") + + monkeypatch.setattr("slcli.function_click.make_api_request", mock_make_api_request) + # Patch workspace map retrieval (indirect path inside utils) + monkeypatch.setattr("slcli.function_click.get_workspace_map", lambda: {"ws1": "Default"}) + + cli = make_cli() + result = runner.invoke(cli, ["function", "manage", "list", "--format", "json"]) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert len(data) == 2 + names = {d["name"] for d in data} + assert {"Adder", "Multiplier"} == names + + +def test_function_get_function_json(monkeypatch: Any, runner: CliRunner) -> None: + """Get a single function by ID and verify JSON output.""" + patch_keyring(monkeypatch) + + def mock_make_api_request( + method: str, url: str, payload=None, headers=None, handle_errors=True + ): + """Mock the API request function for testing. + + Returns a mock response for specific function and workspace URLs. + Raises AssertionError for unexpected URLs. + """ + if "/functions/func-1" in url and method == "GET": + return _MockResponse( + { + "id": "func-1", + "name": "Adder", + "workspaceId": "ws1", + "version": "1.0.0", + "interface": {"entrypoint": "adder"}, + } + ) + if url.endswith("/niuser/v1/workspaces?take=1000"): + return _MockResponse({"workspaces": [{"id": "ws1", "name": "Default"}]}) + raise AssertionError(f"Unexpected URL {url}") + + monkeypatch.setattr("slcli.function_click.make_api_request", mock_make_api_request) + monkeypatch.setattr("slcli.function_click.get_workspace_map", lambda: {"ws1": "Default"}) + + cli = make_cli() + result = runner.invoke( + cli, + ["function", "manage", "get", "--id", "func-1", "--format", "json"], + ) + assert result.exit_code == 0, result.output + obj = json.loads(result.output) + assert obj["id"] == "func-1" + assert obj["interface"]["entrypoint"] == "adder" + + +def test_function_execute_sync_json(monkeypatch: Any, runner: CliRunner) -> None: + """Execute a function synchronously and verify result output.""" + patch_keyring(monkeypatch) + + def mock_make_api_request( + method: str, url: str, payload=None, headers=None, handle_errors=True + ): + """Mock implementation of make_api_request for testing. + + Returns canned responses for function execution and workspace queries. + Asserts payload structure for synchronous execution. + """ + if url.endswith("/functions/func-1/execute") and method == "POST": + # Ensure async flag False for sync command and body wrapping + assert isinstance(payload, dict) and payload.get("async") is False + params = payload.get("parameters", {}) + # Legacy simple parameters should be wrapped inside body only + assert params == {"body": {"a": 5, "b": 10}} + return _MockResponse( + { + "executionId": "exec-123", + "executionTime": 42, + "cachedResult": False, + "result": {"sum": 15}, + } + ) + if url.endswith("/niuser/v1/workspaces?take=1000"): + return _MockResponse({"workspaces": []}) + raise AssertionError(f"Unexpected URL {url}") + + monkeypatch.setattr("slcli.function_click.make_api_request", mock_make_api_request) + monkeypatch.setattr("slcli.function_click.get_workspace_map", lambda: {}) + + cli = make_cli() + result = runner.invoke( + cli, + [ + "function", + "execute", + "sync", + "--function-id", + "func-1", + "--parameters", + '{"a": 5, "b": 10}', + "--format", + "json", + ], + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["result"]["sum"] == 15 + + +def test_function_execute_sync_defaults(monkeypatch: Any, runner: CliRunner) -> None: + """Execute sync with no parameters should use embedded defaults {method:GET,path:/}.""" + patch_keyring(monkeypatch) + + def mock_make_api_request( + method: str, url: str, payload=None, headers=None, handle_errors=True + ): + """Mock API request function for testing sync function execution. + + Returns canned responses for specific URLs and methods, simulating + the behavior of the real API. Raises AssertionError for unexpected calls. + """ + if url.endswith("/functions/func-1/execute") and method == "POST": + assert isinstance(payload, dict) + params = payload.get("parameters", {}) + assert params == {"method": "GET", "path": "/"} + return _MockResponse( + { + "executionId": "exec-000", + "executionTime": 1, + "cachedResult": False, + "result": {"ok": True}, + } + ) + if url.endswith("/niuser/v1/workspaces?take=1000"): + return _MockResponse({"workspaces": []}) + raise AssertionError(f"Unexpected URL {url}") + + monkeypatch.setattr("slcli.function_click.make_api_request", mock_make_api_request) + monkeypatch.setattr("slcli.function_click.get_workspace_map", lambda: {}) + + cli = make_cli() + result = runner.invoke( + cli, + [ + "function", + "execute", + "sync", + "--function-id", + "func-1", + "--format", + "json", + ], + ) + assert result.exit_code == 0, result.output + out = json.loads(result.output) + assert out["result"]["ok"] is True + + +## Async execution command removed; corresponding test deleted. + + +def test_function_get_function_table_interface_summary(monkeypatch: Any, runner: CliRunner) -> None: + """Table output should include concise interface summary when endpoints exist.""" + patch_keyring(monkeypatch) + + def mock_make_api_request( + method: str, url: str, payload=None, headers=None, handle_errors=True + ): + """Mock implementation of the API request function for testing. + + Handles GET requests to "/functions/func-1" by returning a mock function + definition with endpoints. Handles requests to "/niuser/v1/workspaces?take=1000" + by returning a mock workspace list. Raises AssertionError for unexpected URLs. + + Args: + method (str): HTTP method (e.g., "GET"). + url (str): The request URL. + payload: Optional request payload. + headers: Optional request headers. + handle_errors: Whether to handle errors (unused in mock). + + Returns: + _MockResponse: A mock response object with the expected data. + """ + if "/functions/func-1" in url and method == "GET": + return _MockResponse( + { + "id": "func-1", + "name": "py-main", + "workspaceId": "ws1", + "version": "1.0.0", + "runtime": "wasm", + "interface": { + "endpoints": [ + { + "path": "/", + "methods": ["GET"], + "description": "Return a random integer", + }, + { + "path": "/stats", + "methods": ["POST"], + "description": "Compute basic statistics", + }, + ], + "defaultPath": "/", + }, + } + ) + if url.endswith("/niuser/v1/workspaces?take=1000"): + return _MockResponse({"workspaces": [{"id": "ws1", "name": "Default"}]}) + raise AssertionError(f"Unexpected URL {url}") + + monkeypatch.setattr("slcli.function_click.make_api_request", mock_make_api_request) + monkeypatch.setattr("slcli.function_click.get_workspace_map", lambda: {"ws1": "Default"}) + + cli = make_cli() + result = runner.invoke(cli, ["function", "manage", "get", "--id", "func-1"]) # table is default + + assert result.exit_code == 0, result.output + output = result.output + assert "Interface:" in output + assert "Default Path: /" in output + assert "- GET / - Return a random integer" in output + assert "- POST /stats - Compute basic statistics" in output + + +def test_function_init_typescript(monkeypatch: Any, runner: CliRunner, tmp_path) -> None: + """Init command downloads and extracts a typescript template (mocked).""" + patch_keyring(monkeypatch) + + # Build an in-memory tar.gz with expected subfolder structure + import tarfile + import io + import time + + tar_bytes = io.BytesIO() + with tarfile.open(fileobj=tar_bytes, mode="w:gz") as tf: + # Root folder prefix (-/) arbitrary for test + base = "repo-branch/function-examples/typescript-hono-function" + file_content = b"console.log('hello');\n" + ti = tarfile.TarInfo(name=f"{base}/src/index.ts") + ti.size = len(file_content) + ti.mtime = int(time.time()) + tf.addfile(ti, io.BytesIO(file_content)) + tar_bytes.seek(0) + + class _TarResp: + status_code = 200 + content = tar_bytes.getvalue() + + monkeypatch.setattr("slcli.function_templates.requests.get", lambda *a, **k: _TarResp()) + + cli = make_cli() + target = tmp_path / "proj" + result = runner.invoke( + cli, + [ + "function", + "init", + "--language", + "ts", + "--directory", + str(target), + "--force", + ], + ) + assert result.exit_code == 0 + assert (target / "src" / "index.ts").exists() + + +def test_function_init_non_empty_no_force(monkeypatch: Any, runner: CliRunner, tmp_path) -> None: + """Init aborts if target non-empty and no --force.""" + patch_keyring(monkeypatch) + # Prepare tarball + import tarfile + import io + import time + + tar_bytes = io.BytesIO() + with tarfile.open(fileobj=tar_bytes, mode="w:gz") as tf: + base = "repo-branch/function-examples/python-http-function" + py_content = b"print('hello')\n" + ti = tarfile.TarInfo(name=f"{base}/main.py") + ti.size = len(py_content) + ti.mtime = int(time.time()) + tf.addfile(ti, io.BytesIO(py_content)) + tar_bytes.seek(0) + + class _TarResp: + status_code = 200 + content = tar_bytes.getvalue() + + monkeypatch.setattr("slcli.function_templates.requests.get", lambda *a, **k: _TarResp()) + + cli = make_cli() + target = tmp_path / "proj" + target.mkdir() + (target / "existing.txt").write_text("x", encoding="utf-8") + result = runner.invoke( + cli, + [ + "function", + "init", + "--language", + "python", + "--directory", + str(target), + ], + ) + assert result.exit_code != 0 + assert "Target directory is not empty" in result.output