diff --git a/.github/workflows/pr-review-codex.yml b/.github/workflows/pr-review-codex.yml new file mode 100644 index 0000000..0a94628 --- /dev/null +++ b/.github/workflows/pr-review-codex.yml @@ -0,0 +1,67 @@ +name: Perform a code review when a pull request is created. +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled] + +jobs: + codex: + runs-on: ubuntu-24.04 + + permissions: + contents: read + pull-requests: write + issues: write + + outputs: + final_message: ${{ steps.run_codex.outputs.final-message }} + steps: + - uses: actions/checkout@v5 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/merge + + - name: Pre-fetch base and head refs for the PR + run: | + git fetch --no-tags origin \ + ${{ github.event.pull_request.base.ref }} \ + +refs/pull/${{ github.event.pull_request.number }}/head + + - name: Run Codex + id: run_codex + uses: openai/codex-action@v1 + with: + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + prompt: | + This is PR #${{ github.event.pull_request.number }} for ${{ github.repository }}. + Base SHA: ${{ github.event.pull_request.base.sha }} + Head SHA: ${{ github.event.pull_request.head.sha }} + + Review ONLY the changes introduced by the PR. + Suggest any improvements, potential bugs, security issues, or issues. + Be concise and specific in your feedback. + + Pull request title and body: + ---- + ${{ github.event.pull_request.title }} + ${{ github.event.pull_request.body }} + + post_feedback: + runs-on: ubuntu-latest + needs: codex + if: needs.codex.outputs.final_message != '' + permissions: + issues: write + pull-requests: write + steps: + - name: Report Codex feedback + uses: actions/github-script@v7 + env: + CODEX_FINAL_MESSAGE: ${{ needs.codex.outputs.final_message }} + with: + github-token: ${{ github.token }} + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: process.env.CODEX_FINAL_MESSAGE, + }); diff --git a/.gitignore b/.gitignore index b765a90..d6b7b16 100644 --- a/.gitignore +++ b/.gitignore @@ -106,4 +106,7 @@ dmypy.json # Example files temp directories tmp/ -.DS_Store \ No newline at end of file +.DS_Store + +# Claude +.claude/ \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..b3989e5 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,148 @@ +# Development Guide + +This guide covers how to set up and test the package locally before publishing. + +## Setup + +### Create a Virtual Environment + +```bash +# Create a virtual environment +python -m venv venv + +# Activate the virtual environment +source venv/bin/activate # On macOS/Linux +# venv\Scripts\activate # On Windows +``` + +### Install in Development Mode + +With the virtual environment activated, install the package with dev dependencies: + +```bash +# Install in editable/development mode with dev dependencies +pip install -e ".[dev]" +``` + +This installs the package along with `python-dotenv` (for `.env` file support), `pytest`, `mypy`, `black`, and `flake8`. + +### Environment Variables + +Set the following environment variables for testing: + +```bash +export FABRICATE_API_KEY="your-api-key" +export FABRICATE_API_URL="https://fabricate.tonic.ai/api/v1" # or your local instance +``` + +Or create a `.env` file (requires `python-dotenv`): + +``` +FABRICATE_API_KEY=your-api-key +FABRICATE_API_URL=https://fabricate.tonic.ai/api/v1 +``` + +## Testing + +### Quick Import Test + +Verify the module imports correctly: + +```bash +python -c "from tonic_fabricate import generate, run_workflow, WorkflowResult; print('All imports work!')" +``` + +### Run the Examples + +The examples test the actual API calls: + +```bash +# Test the generate function +python examples/download.py + +# Test the workflow function +python examples/workflow.py +``` + +### Interactive Testing + +```python +from tonic_fabricate import run_workflow +import os + +result = run_workflow( + database='your_database', + workspace='your_workspace', + workflow='your_workflow', + api_url=os.environ.get('FABRICATE_API_URL'), + on_progress=lambda p: print(p) +) +print(result.result) +``` + +## Code Quality + +### Install Dev Dependencies + +```bash +pip install -r requirements-dev.txt +``` + +### Type Checking + +```bash +mypy tonic_fabricate/ +``` + +### Linting + +```bash +flake8 tonic_fabricate/ +``` + +### Formatting + +```bash +# Check formatting +black --check tonic_fabricate/ + +# Auto-format +black tonic_fabricate/ +``` + +## Pre-Publish Testing + +Before publishing to production PyPI, test with TestPyPI: + +```bash +# Publish to TestPyPI +./publish-test.sh + +# Install from TestPyPI to verify +pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ tonic-fabricate==1.1.0 + +# Test the installed package +python -c "from tonic_fabricate import generate, run_workflow; print('Package works!')" +``` + +## Publishing + +See [publishing.md](publishing.md) for detailed publishing instructions. + +### Quick Reference + +```bash +# Test publish +./publish-test.sh + +# Production publish (after testing) +./publish.sh +``` + +## Deactivating the Virtual Environment + +When you're done developing: + +```bash +deactivate +``` diff --git a/README.md b/README.md index 889cd56..6e64837 100644 --- a/README.md +++ b/README.md @@ -109,12 +109,80 @@ The client will automatically use the following environment variables if they ar - `FABRICATE_API_KEY`: Your Fabricate API key - `FABRICATE_API_URL`: The Fabricate API URL (defaults to https://fabricate.tonic.ai/api/v1) +## Workflows + +Fabricate supports workflows that can perform custom operations and generate files. To run a workflow: + +```python +from tonic_fabricate import run_workflow + +result = run_workflow( + # The workspace to use + workspace='Default', + + # The name of the database + database='my_database', + + # The name of the workflow to run + workflow='my_workflow', + + # Optional: Parameters to pass to the workflow + params={ + 'message': 'Hello, world!', + }, +) + +# Access the workflow result +print(f"Result: {result.result}") + +# Download generated files if any +if result.task.files: + for file in result.task.files: + print(f"File: {file.name} ({file.size} bytes)") + result.download_file(file.id, f"./output/{file.name}") + + # Or download all files at once + result.download_all_files('./output') +``` + +### Workflow Progress Tracking + +```python +from tonic_fabricate import run_workflow + +def on_progress(data): + status = data.get('status', '') + message = data.get('message', '') + print(f"[{status}] {message}") + +result = run_workflow( + workspace='Default', + database='my_database', + workflow='my_workflow', + on_progress=on_progress +) +``` + +### Workflow File Downloads + +You can also download workflow files directly using `download_workflow_file`: + +```python +from tonic_fabricate import download_workflow_file + +download_workflow_file( + task_id='your-task-id', + file_id=123, + dest_path='./output/file.txt' +) +``` + ## Error Handling The client raises appropriate exceptions for various error conditions: ```python -from tonic_fabricate import generate +from tonic_fabricate import generate, run_workflow try: generate( @@ -127,4 +195,15 @@ except ValueError as e: print(f"Invalid parameters: {e}") except Exception as e: print(f"Generation failed: {e}") + +try: + result = run_workflow( + workspace='Default', + database='my_database', + workflow='my_workflow' + ) +except ValueError as e: + print(f"Invalid parameters: {e}") +except Exception as e: + print(f"Workflow failed: {e}") ``` diff --git a/examples/download.py b/examples/download.py index a9279b5..cbcbdc4 100644 --- a/examples/download.py +++ b/examples/download.py @@ -7,14 +7,10 @@ import sys sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from tonic_fabricate import generate +from dotenv import load_dotenv +load_dotenv() -# Load environment variables if python-dotenv is available -try: - from dotenv import load_dotenv - load_dotenv() -except ImportError: - pass +from tonic_fabricate import generate def on_progress(data): """Progress callback function.""" diff --git a/examples/workflow.py b/examples/workflow.py new file mode 100644 index 0000000..74090db --- /dev/null +++ b/examples/workflow.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Example script for running a workflow in Fabricate. +""" + +import os +import sys +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from dotenv import load_dotenv +load_dotenv() + +from tonic_fabricate import run_workflow + +def on_progress(data): + """Progress callback function.""" + status = data.get('status', '') + message = data.get('message', '') + print(f"[{status}] {message}") + +if __name__ == "__main__": + print("Starting workflow...") + + # Prepare arguments + kwargs = { + 'database': 'agent_api_test', + 'workspace': 'API', + 'workflow': 'file', + 'params': { + 'message': 'Hello, world!', + }, + 'on_progress': on_progress, + } + + # Add api_url if available + api_url = os.environ.get('FABRICATE_API_URL') + if api_url: + kwargs['api_url'] = api_url + + # Run the workflow + workflow_result = run_workflow(**kwargs) + + print(f"Workflow result: {workflow_result.result}") + + # List and download files if any were generated + if workflow_result.task.files: + print(f"\nWorkflow generated {len(workflow_result.task.files)} file(s):") + + dest_dir = './tmp/workflow_output' + + for file in workflow_result.task.files: + print(f" - {file.name} ({file.content_type}, {file.size} bytes, id: {file.id})") + + # Download a specific file by id + workflow_result.download_file(file.id, f"{dest_dir}/{file.name}") + + # Or, download all files to a directory + # print('\nDownloading all files...') + # workflow_result.download_all_files(dest_dir) + # print(f'Files downloaded to {dest_dir}/') + + print("Done.") diff --git a/pyproject.toml b/pyproject.toml index 7756280..53760f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tonic-fabricate" -version = "1.0.2" +version = "1.1.0" description = "The official Fabricate client for Python" authors = [{name = "Fabricate Tools"}] readme = "README.md" diff --git a/tonic_fabricate/__init__.py b/tonic_fabricate/__init__.py index 211d230..624760e 100644 --- a/tonic_fabricate/__init__.py +++ b/tonic_fabricate/__init__.py @@ -2,8 +2,22 @@ Tonic Fabricate - The official Fabricate client for Python. """ -from .client import generate +from .client import ( + generate, + run_workflow, + download_workflow_file, + WorkflowFile, + WorkflowTask, + WorkflowResult, +) -__version__ = "1.0.1" +__version__ = "1.1.0" -__all__ = ["generate"] \ No newline at end of file +__all__ = [ + "generate", + "run_workflow", + "download_workflow_file", + "WorkflowFile", + "WorkflowTask", + "WorkflowResult", +] diff --git a/tonic_fabricate/client.py b/tonic_fabricate/client.py index 9bcf730..7e77575 100644 --- a/tonic_fabricate/client.py +++ b/tonic_fabricate/client.py @@ -3,10 +3,302 @@ import zipfile import shutil from pathlib import Path -from typing import Optional, Callable, Dict, Any, Union +from typing import Optional, Callable, Dict, Any, Union, List +from dataclasses import dataclass +from urllib.parse import quote import requests +@dataclass +class WorkflowFile: + """Represents a file generated by a workflow.""" + id: int + name: str + size: int + content_type: str + + +@dataclass +class WorkflowTask: + """Represents a workflow task.""" + id: str + workflow_id: str + status: str # 'in_progress', 'completed', 'failed', 'canceled' + result: Optional[Any] = None + files: Optional[List[WorkflowFile]] = None + error: Optional[str] = None + started_at: Optional[str] = None + completed_at: Optional[str] = None + created_at: Optional[str] = None + + +@dataclass +class WorkflowResult: + """Result of running a workflow, with helpers to download generated files.""" + result: Any + task: WorkflowTask + _api_key: str + _api_url: str + + def download_file(self, file_id: int, dest_path: str) -> None: + """ + Downloads a file from the workflow task. + + Args: + file_id: The ID of the file to download (from task.files[].id) + dest_path: The destination path to save the file + """ + download_workflow_file( + api_key=self._api_key, + api_url=self._api_url, + task_id=self.task.id, + file_id=file_id, + dest_path=dest_path + ) + + def download_all_files(self, dest_dir: str) -> None: + """ + Downloads all files from the workflow task to a directory. + + Args: + dest_dir: The destination directory to save the files + """ + if not self.task.files: + return + + dest_path = Path(dest_dir) + dest_path.mkdir(parents=True, exist_ok=True) + + for file in self.task.files: + # Sanitize filename to prevent path traversal attacks + safe_name = Path(file.name).name + file_dest = dest_path / safe_name + self.download_file(file.id, str(file_dest)) + + +def run_workflow( + database: str, + workspace: str, + workflow: str, + api_key: Optional[str] = None, + api_url: str = "https://fabricate.tonic.ai/api/v1", + params: Optional[Dict[str, Any]] = None, + on_progress: Optional[Callable[[Dict[str, str]], None]] = None, +) -> WorkflowResult: + """ + Runs a workflow and waits for the result. + + Args: + database: The name of the database (required) + workspace: The workspace to use (required) + workflow: The name of the workflow to run (required) + api_key: The API key for authentication. Defaults to FABRICATE_API_KEY env var. + api_url: The API URL. Defaults to https://fabricate.tonic.ai/api/v1 + params: Optional parameters to pass to the workflow + on_progress: Optional progress callback function receiving {'status': str, 'message': str} + + Returns: + WorkflowResult containing the result, task, and file download methods + + Raises: + ValueError: If required parameters are missing + Exception: If workflow execution fails + """ + # Get API key from parameter or environment + if api_key is None: + api_key = os.environ.get('FABRICATE_API_KEY') + + # Validate required parameters + if not api_key: + raise ValueError('api_key is required') + + if not database: + raise ValueError('database is required') + + if not workspace: + raise ValueError('workspace is required') + + if not workflow: + raise ValueError('workflow is required') + + if params is None: + params = {} + + headers = {'Authorization': f'Bearer {api_key}'} + url = ( + f'{api_url}/workspaces/{quote(workspace, safe="")}' + f'/databases/{quote(database, safe="")}' + f'/workflows/{quote(workflow, safe="")}' + ) + + try: + response = requests.post(url, json=params, headers=headers) + response.raise_for_status() + data = response.json() + except requests.exceptions.HTTPError as e: + if e.response is not None and 'application/json' in e.response.headers.get('content-type', ''): + error_data = e.response.json() + raise Exception(error_data.get('error', str(e))) + else: + raise + + task_id = data.get('task_id') + status = data.get('status', '') + + if not task_id: + raise Exception('No task_id returned from API') + + if on_progress: + on_progress({'status': status, 'message': 'Workflow started'}) + + # Poll for completion + task = _poll_workflow_task(task_id, api_url, api_key, on_progress) + + if task.error: + raise Exception(task.error) + + return WorkflowResult( + result=task.result, + task=task, + _api_key=api_key, + _api_url=api_url + ) + + +def _poll_workflow_task( + task_id: str, + api_url: str, + api_key: str, + on_progress: Optional[Callable[[Dict[str, str]], None]] = None +) -> WorkflowTask: + """ + Polls the workflow task API until the task is completed. + + Args: + task_id: The ID of the task to poll + api_url: The API URL + api_key: The API key + on_progress: Optional progress callback + + Returns: + The completed WorkflowTask + + Raises: + Exception: If task fails or is canceled + """ + headers = {'Authorization': f'Bearer {api_key}'} + + while True: + response = requests.get( + f'{api_url}/workflow_tasks/{task_id}', + headers=headers + ) + response.raise_for_status() + data = response.json() + + task = _parse_workflow_task(data) + + if task.status == 'completed': + if on_progress: + on_progress({'status': 'completed', 'message': 'Workflow completed'}) + return task + elif task.status == 'failed': + raise Exception(task.error or 'Workflow failed') + elif task.status == 'canceled': + raise Exception('Workflow was canceled') + else: + if on_progress: + on_progress({'status': task.status, 'message': 'Workflow running...'}) + time.sleep(1) + + +def _parse_workflow_task(data: Dict[str, Any]) -> WorkflowTask: + """Parses a workflow task from API response data.""" + files = None + if data.get('files'): + files = [ + WorkflowFile( + id=f['id'], + name=f['name'], + size=f['size'], + content_type=f['content_type'] + ) + for f in data['files'] + ] + + return WorkflowTask( + id=data['id'], + workflow_id=data.get('workflow_id', ''), + status=data['status'], + result=data.get('result'), + files=files, + error=data.get('error'), + started_at=data.get('started_at'), + completed_at=data.get('completed_at'), + created_at=data.get('created_at') + ) + + +def download_workflow_file( + task_id: str, + file_id: int, + dest_path: str, + api_key: Optional[str] = None, + api_url: str = "https://fabricate.tonic.ai/api/v1", +) -> None: + """ + Downloads a file from a workflow task. + + Args: + task_id: The ID of the workflow task + file_id: The ID of the file to download + dest_path: The destination path to save the file + api_key: The API key for authentication. Defaults to FABRICATE_API_KEY env var. + api_url: The API URL. Defaults to https://fabricate.tonic.ai/api/v1 + + Raises: + ValueError: If required parameters are missing + requests.HTTPError: If download fails + """ + # Get API key from parameter or environment + if api_key is None: + api_key = os.environ.get('FABRICATE_API_KEY') + + if not api_key: + raise ValueError('api_key is required') + + if not task_id: + raise ValueError('task_id is required') + + if not file_id: + raise ValueError('file_id is required') + + if not dest_path: + raise ValueError('dest_path is required') + + # Ensure the directory exists + dest_file = Path(dest_path) + dest_dir = dest_file.parent + + if dest_dir != Path('.') and not dest_dir.exists(): + dest_dir.mkdir(parents=True, exist_ok=True) + + url = f'{api_url}/workflow_tasks/{task_id}/{file_id}/download' + headers = {'Authorization': f'Bearer {api_key}'} + + try: + with requests.get(url, headers=headers, stream=True) as response: + response.raise_for_status() + with open(dest_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + except Exception: + # Clean up partial file on error + if dest_file.exists(): + dest_file.unlink() + raise + + def generate( api_key: Optional[str] = None, api_url: str = "https://fabricate.tonic.ai/api/v1",