artifact demo
This commit is contained in:
294
actions/artifact_demo.py
Normal file
294
actions/artifact_demo.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Artifact Demo Action - Python Example Pack
|
||||||
|
|
||||||
|
Demonstrates creating file and progress artifacts via the Attune API.
|
||||||
|
Each iteration:
|
||||||
|
1. Appends a timestamped log line to a file artifact (via version upload)
|
||||||
|
2. Updates a progress artifact by 2%
|
||||||
|
3. Sleeps for 0.5 seconds
|
||||||
|
|
||||||
|
The action authenticates to the API using the provided credentials (or defaults)
|
||||||
|
and uses ATTUNE_API_URL / ATTUNE_EXEC_ID environment variables set by the worker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def api_request(
|
||||||
|
base_url, path, method="GET", data=None, token=None, content_type="application/json"
|
||||||
|
):
|
||||||
|
"""Make an HTTP request to the Attune API."""
|
||||||
|
url = f"{base_url}{path}"
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
body = None
|
||||||
|
if data is not None:
|
||||||
|
if content_type == "application/json":
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
else:
|
||||||
|
body = data if isinstance(data, bytes) else data.encode("utf-8")
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8")), resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"API error {e.code} on {method} {path}: {error_body}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def multipart_upload(
|
||||||
|
base_url, path, file_bytes, filename, token, content_type="text/plain"
|
||||||
|
):
|
||||||
|
"""Upload a file via multipart/form-data."""
|
||||||
|
boundary = f"----AttuneArtifact{int(time.time() * 1000)}"
|
||||||
|
body_parts = []
|
||||||
|
|
||||||
|
# file field
|
||||||
|
body_parts.append(f"--{boundary}\r\n".encode())
|
||||||
|
body_parts.append(
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
|
||||||
|
)
|
||||||
|
body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
|
||||||
|
body_parts.append(file_bytes)
|
||||||
|
body_parts.append(b"\r\n")
|
||||||
|
|
||||||
|
# content_type field
|
||||||
|
body_parts.append(f"--{boundary}\r\n".encode())
|
||||||
|
body_parts.append(b'Content-Disposition: form-data; name="content_type"\r\n\r\n')
|
||||||
|
body_parts.append(content_type.encode())
|
||||||
|
body_parts.append(b"\r\n")
|
||||||
|
|
||||||
|
# created_by field
|
||||||
|
body_parts.append(f"--{boundary}\r\n".encode())
|
||||||
|
body_parts.append(b'Content-Disposition: form-data; name="created_by"\r\n\r\n')
|
||||||
|
body_parts.append(b"python_example.artifact_demo")
|
||||||
|
body_parts.append(b"\r\n")
|
||||||
|
|
||||||
|
body_parts.append(f"--{boundary}--\r\n".encode())
|
||||||
|
|
||||||
|
full_body = b"".join(body_parts)
|
||||||
|
|
||||||
|
url = f"{base_url}{path}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=full_body, headers=headers, method="POST")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8")), resp.status
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"Upload error {e.code} on {path}: {error_body}", file=sys.stderr)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def login(base_url, username, password):
|
||||||
|
"""Authenticate and return a JWT token."""
|
||||||
|
data, _ = api_request(
|
||||||
|
base_url,
|
||||||
|
"/auth/login",
|
||||||
|
method="POST",
|
||||||
|
data={
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return data["data"]["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def create_artifact(
|
||||||
|
base_url,
|
||||||
|
token,
|
||||||
|
ref,
|
||||||
|
name,
|
||||||
|
artifact_type,
|
||||||
|
execution_id,
|
||||||
|
content_type=None,
|
||||||
|
description=None,
|
||||||
|
data=None,
|
||||||
|
):
|
||||||
|
"""Create a new artifact and return its ID."""
|
||||||
|
payload = {
|
||||||
|
"ref": ref,
|
||||||
|
"scope": "action",
|
||||||
|
"owner": "python_example.artifact_demo",
|
||||||
|
"type": artifact_type,
|
||||||
|
"retention_policy": "versions",
|
||||||
|
"retention_limit": 10,
|
||||||
|
"name": name,
|
||||||
|
"execution": execution_id,
|
||||||
|
}
|
||||||
|
if content_type:
|
||||||
|
payload["content_type"] = content_type
|
||||||
|
if description:
|
||||||
|
payload["description"] = description
|
||||||
|
if data is not None:
|
||||||
|
payload["data"] = data
|
||||||
|
|
||||||
|
resp, _ = api_request(
|
||||||
|
base_url, "/api/v1/artifacts", method="POST", data=payload, token=token
|
||||||
|
)
|
||||||
|
return resp["data"]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def append_progress(base_url, token, artifact_id, entry):
|
||||||
|
"""Append an entry to a progress artifact."""
|
||||||
|
api_request(
|
||||||
|
base_url,
|
||||||
|
f"/api/v1/artifacts/{artifact_id}/progress",
|
||||||
|
method="POST",
|
||||||
|
data={"entry": entry},
|
||||||
|
token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Read parameters from stdin (JSON format)
|
||||||
|
params = json.loads(sys.stdin.readline())
|
||||||
|
iterations = params.get("iterations", 50)
|
||||||
|
username = params.get("username") or os.environ.get(
|
||||||
|
"ATTUNE_USERNAME", "test@attune.local"
|
||||||
|
)
|
||||||
|
password = params.get("password") or os.environ.get(
|
||||||
|
"ATTUNE_PASSWORD", "TestPass123!"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get execution context from environment
|
||||||
|
api_url = os.environ.get("ATTUNE_API_URL", "http://localhost:8080")
|
||||||
|
exec_id_str = os.environ.get("ATTUNE_EXEC_ID", "")
|
||||||
|
execution_id = int(exec_id_str) if exec_id_str else None
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Artifact demo starting: {iterations} iterations, API at {api_url}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Authenticate
|
||||||
|
token = login(api_url, username, password)
|
||||||
|
print("Authenticated successfully", file=sys.stderr)
|
||||||
|
|
||||||
|
# Build unique artifact refs using execution ID to avoid collisions
|
||||||
|
ts = int(time.time())
|
||||||
|
ref_suffix = f"{execution_id}_{ts}" if execution_id else str(ts)
|
||||||
|
file_ref = f"python_example.artifact_demo.log.{ref_suffix}"
|
||||||
|
progress_ref = f"python_example.artifact_demo.progress.{ref_suffix}"
|
||||||
|
|
||||||
|
# Create file artifact (file_text type)
|
||||||
|
file_artifact_id = create_artifact(
|
||||||
|
base_url=api_url,
|
||||||
|
token=token,
|
||||||
|
ref=file_ref,
|
||||||
|
name="Artifact Demo Log",
|
||||||
|
artifact_type="file_text",
|
||||||
|
execution_id=execution_id,
|
||||||
|
content_type="text/plain",
|
||||||
|
description=f"Log output from artifact demo ({iterations} iterations)",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Created file artifact ID={file_artifact_id} ref={file_ref}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create progress artifact
|
||||||
|
progress_artifact_id = create_artifact(
|
||||||
|
base_url=api_url,
|
||||||
|
token=token,
|
||||||
|
ref=progress_ref,
|
||||||
|
name="Artifact Demo Progress",
|
||||||
|
artifact_type="progress",
|
||||||
|
execution_id=execution_id,
|
||||||
|
description=f"Progress tracker for artifact demo ({iterations} iterations)",
|
||||||
|
data=[], # Initialize with empty array
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Created progress artifact ID={progress_artifact_id} ref={progress_ref}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run iterations
|
||||||
|
log_lines = []
|
||||||
|
for i in range(iterations):
|
||||||
|
iteration = i + 1
|
||||||
|
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
pct = min(round(iteration * (100.0 / iterations), 1), 100.0)
|
||||||
|
|
||||||
|
# Build log line
|
||||||
|
log_line = f"[{now}] Iteration {iteration}/{iterations} — progress {pct}%"
|
||||||
|
log_lines.append(log_line)
|
||||||
|
|
||||||
|
print(f" Iteration {iteration}/{iterations} ({pct}%)", file=sys.stderr)
|
||||||
|
|
||||||
|
# Upload the full log as a new file version
|
||||||
|
full_log = "\n".join(log_lines) + "\n"
|
||||||
|
multipart_upload(
|
||||||
|
base_url=api_url,
|
||||||
|
path=f"/api/v1/artifacts/{file_artifact_id}/versions/upload",
|
||||||
|
file_bytes=full_log.encode("utf-8"),
|
||||||
|
filename="artifact_demo.log",
|
||||||
|
token=token,
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append progress entry
|
||||||
|
append_progress(
|
||||||
|
api_url,
|
||||||
|
token,
|
||||||
|
progress_artifact_id,
|
||||||
|
{
|
||||||
|
"iteration": iteration,
|
||||||
|
"total": iterations,
|
||||||
|
"percent": pct,
|
||||||
|
"message": f"Completed iteration {iteration}",
|
||||||
|
"timestamp": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sleep between iterations
|
||||||
|
if iteration < iterations:
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
elapsed = round(time.time() - start_time, 3)
|
||||||
|
result = {
|
||||||
|
"file_artifact_id": file_artifact_id,
|
||||||
|
"progress_artifact_id": progress_artifact_id,
|
||||||
|
"iterations_completed": iterations,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
"success": True,
|
||||||
|
}
|
||||||
|
print(json.dumps(result))
|
||||||
|
print(f"Artifact demo completed in {elapsed}s", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = round(time.time() - start_time, 3)
|
||||||
|
error_result = {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result))
|
||||||
|
print(f"ERROR: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
68
actions/artifact_demo.yaml
Normal file
68
actions/artifact_demo.yaml
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Artifact Demo Action
|
||||||
|
# Demonstrates creating file and progress artifacts via the Attune API
|
||||||
|
|
||||||
|
ref: python_example.artifact_demo
|
||||||
|
label: "Artifact Demo"
|
||||||
|
description: "Creates a file artifact and a progress artifact, writing lines and updating progress over multiple iterations"
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# Runner type determines how the action is executed
|
||||||
|
runner_type: python
|
||||||
|
|
||||||
|
# Minimum Python version required (semver constraint)
|
||||||
|
runtime_version: ">=3.9"
|
||||||
|
|
||||||
|
# Entry point is the Python script to execute
|
||||||
|
entry_point: artifact_demo.py
|
||||||
|
|
||||||
|
# Parameter delivery: stdin for secure parameter passing
|
||||||
|
parameter_delivery: stdin
|
||||||
|
parameter_format: json
|
||||||
|
|
||||||
|
# Output format: json (structured data parsing enabled)
|
||||||
|
output_format: json
|
||||||
|
|
||||||
|
# Action parameters schema (flat format with inline required/secret)
|
||||||
|
parameters:
|
||||||
|
iterations:
|
||||||
|
type: integer
|
||||||
|
description: "Number of iterations to run (each adds a log line and 2% progress)"
|
||||||
|
default: 50
|
||||||
|
minimum: 1
|
||||||
|
maximum: 200
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: "API username for authentication (defaults to ATTUNE_USERNAME env var or test@attune.local)"
|
||||||
|
default: "test@attune.local"
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: "API password for authentication (defaults to ATTUNE_PASSWORD env var or TestPass123!)"
|
||||||
|
secret: true
|
||||||
|
default: "TestPass123!"
|
||||||
|
|
||||||
|
# Output schema (flat format)
|
||||||
|
output_schema:
|
||||||
|
file_artifact_id:
|
||||||
|
type: integer
|
||||||
|
description: "ID of the created file artifact"
|
||||||
|
required: true
|
||||||
|
progress_artifact_id:
|
||||||
|
type: integer
|
||||||
|
description: "ID of the created progress artifact"
|
||||||
|
required: true
|
||||||
|
iterations_completed:
|
||||||
|
type: integer
|
||||||
|
description: "Number of iterations completed"
|
||||||
|
required: true
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
description: "Whether the demo completed successfully"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# Tags for categorization
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- example
|
||||||
|
- artifacts
|
||||||
|
- progress
|
||||||
|
- demo
|
||||||
Reference in New Issue
Block a user