From fd532d082b0e3becbfdb2900685e10ee8b6614ce Mon Sep 17 00:00:00 2001 From: Aaron Elliot Ross Date: Thu, 4 Dec 2025 16:13:24 +0100 Subject: [PATCH] Add command-line interface for ActionKit API This commit introduces a new CLI tool for interacting with the ActionKit API, making it easy to perform CRUD operations from the command line. Features: - Intuitive command structure: actionkit [METHOD] RESOURCE [PARAMS...] - Support for all ActionKit resources (users, donations, campaigns, etc.) - Smart parameter parsing with automatic type conversion - Multiple authentication methods (env vars or CLI options) - Pretty-printed JSON output - Helpful error messages and resource discovery Technical changes: - Add actionkit/cli.py with Click-based CLI implementation - Update setup.py to add click dependency and console_scripts entry point - Fix regex pattern in Users.id() to use raw string - Add CLAUDE.md for future Claude Code context - Add CLI_USAGE.md with comprehensive usage examples Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 184 +++++++++++++++++++++++++++++++ CLI_USAGE.md | 226 ++++++++++++++++++++++++++++++++++++++ actionkit/cli.py | 262 +++++++++++++++++++++++++++++++++++++++++++++ actionkit/users.py | 2 +- setup.py | 7 +- 5 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 CLI_USAGE.md create mode 100644 actionkit/cli.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..29120ab --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,184 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a Python client library for the ActionKit REST API, used internally by WeMove. It provides a clean interface for interacting with ActionKit resources like donations, users, campaigns, petitions, and more. + +## Package Information + +- **Package name**: `actionkit` (version 0.3.5) +- **Dependencies**: `requests`, `requests-toolbelt` +- **Distribution**: Published to WeMove's internal GitLab PyPI repository + +## Development Commands + +```bash +# Install in development mode +pip install -e . + +# Build the package +python setup.py sdist bdist_wheel + +# Upload to GitLab (requires ~/.pypirc configuration) +python3 -m twine upload --verbose --repository gitlab dist/* + +# Run tests +python -m unittest discover tests +``` + +## Command-Line Interface + +The package includes a CLI tool for interacting with the ActionKit API: + +```bash +# General syntax +actionkit [METHOD] RESOURCE [PARAMS...] + +# Examples +actionkit users id=123 # Get user by ID (default method) +actionkit get users id=123 # Explicit GET +actionkit search users email=test@example.com +actionkit post users email=new@example.com first_name=John +actionkit patch users/123 first_name=Jane +actionkit delete users/123 + +# Other resources +actionkit orders id=456 +actionkit donations id=789 +actionkit search campaigns name=climate +``` + +Authentication is handled via environment variables (`ACTIONKIT_USERNAME`, `ACTIONKIT_PASSWORD`, `ACTIONKIT_HOSTNAME`) or CLI options (`--hostname`, `--username`, `--password`). + +## Architecture + +### Connection and Authentication + +The library uses a connection-based architecture: + +1. **Connection class** (`actionkit/connection.py`): Manages HTTPS sessions with the ActionKit API + - Handles authentication via HTTP Basic Auth + - Implements automatic retry logic for flaky API responses (retries on 500 errors with exponential backoff) + - Provides `get()`, `post()`, `patch()`, `put()`, `delete()` methods + - Normalizes API paths (handles absolute URLs, API paths, or relative paths) + +2. **Authentication**: Uses environment variables or explicit parameters: + - `ACTIONKIT_USERNAME` + - `ACTIONKIT_PASSWORD` + - `ACTIONKIT_HOSTNAME` + +### Resource Classes + +All resource classes inherit from `HttpMethods` base class (`actionkit/httpmethods.py`), which provides: +- Standard CRUD operations: `get()`, `post()`, `patch()`, `put()`, `delete()`, `search()` +- Resource URI manipulation utilities +- Automatic pagination handling in `search()` method + +Each resource class must define a `resource_name` property (e.g., `"user"`, `"donationaction"`, `"petitionpage"`). + +### Main Entry Points + +**High-level API** (`actionkit/__init__.py`): +```python +import actionkit +ak = actionkit.ActionKit(hostname, username, password) +# Or use environment variables: +ak = actionkit.ActionKit() +``` + +The `ActionKit` class exposes all resource endpoints as attributes: +- `ak.Users`, `ak.DonationAction`, `ak.Orders`, `ak.Campaigns`, etc. + +**Low-level connection** (for direct API access): +```python +connection = actionkit.connect(hostname, username, password) +response = connection.get("/rest/v1/user/123/") +``` + +### Key Resource Classes + +- **DonationAction** (`donationaction.py`): Most complex resource with specialized methods + - `push()`: Create new donations via the donationpush endpoint + - `set_push_status()`: Update donation/order/transaction statuses + - Status wrappers: `set_push_status_completed()`, `set_push_status_failed()`, `set_push_status_pending()`, `set_push_status_incomplete()` + - Recurring donation support via `recurring_id` parameter + - `extract_resource_uris()`: Helper to get order/transaction URIs from donation data + +- **Users** (`users.py`): User management + - `get_by_email()`: Search users by email + - `get_by_akid()`: Retrieve user by ActionKit ID (with HMAC verification) + +- **Petitions** (`petitions.py`): Petition page creation + - `create()`: Create petition with form and followup + - `create_from_model()`: Clone petition from existing template + +- **Campaigns** (`campaigns.py`): WeMove-specific campaign logic + - Uses signuppage resource but adds WeMove conventions + - `create()`: Creates campaign page and updates field choices + +### Validation and Error Handling + +- **ValidationError** (`validation.py`): Custom exception for API validation errors +- **AKID verification** (`utils.py`): HMAC-based verification using `ACTIONKIT_SECRET_KEY` +- **HTTPError handling**: Most methods catch and re-raise with additional context + +### Utilities + +- `utils.py`: Datetime conversion helpers, AKID verification +- `validation.py`: Timezone-aware datetime validation, custom ValidationError + +## Common Patterns + +### Creating a donation +```python +ak = actionkit.ActionKit() +response = ak.DonationAction.push( + email="user@example.com", + first_name="John", + last_name="Doe", + country="US", + postal="12345", + amount=Decimal("25.00"), + currency="USD", + page="donate-page", + payment_account="/rest/v1/paymentaccount/1/", + recurring_id="sub_123" # Optional for recurring +) +``` + +### Updating donation status +```python +# Using response data directly +action = response.json() +ak.DonationAction.set_push_status_completed( + donationaction_data=action, + trans_id="ch_123456" +) + +# Or using resource_uri +ak.DonationAction.set_push_status_failed( + resource_uri="/rest/v1/donationaction/12345/", + failure_message="Card declined" +) +``` + +### Searching and pagination +```python +# Returns all users matching criteria (auto-paginates) +users = ak.Users.search(country="US", limit=100) +``` + +## Testing + +Tests are in `tests/` directory. Currently minimal test coverage (one test file for path handling). + +To run tests: +```bash +python -m unittest discover tests +``` + +## Publishing Workflow + +See `README.txt` for detailed instructions on publishing to GitLab PyPI repository (project ID: 62). diff --git a/CLI_USAGE.md b/CLI_USAGE.md new file mode 100644 index 0000000..410e44e --- /dev/null +++ b/CLI_USAGE.md @@ -0,0 +1,226 @@ +# ActionKit CLI Usage Guide + +The ActionKit CLI provides a simple, predictable command-line interface for interacting with the ActionKit REST API. + +## Installation + +```bash +pip install -e . +``` + +## Authentication + +Set environment variables: + +```bash +export ACTIONKIT_HOSTNAME=your-hostname.actionkit.com +export ACTIONKIT_USERNAME=your-username +export ACTIONKIT_PASSWORD=your-password +``` + +Or use `.envrc` with direnv (recommended): + +```bash +export ACTIONKIT_HOSTNAME=your-hostname.actionkit.com +export ACTIONKIT_USERNAME=your-username +export ACTIONKIT_PASSWORD=your-password +``` + +Alternatively, pass credentials via CLI options: + +```bash +actionkit --hostname example.actionkit.com --username user --password pass users id=123 +``` + +## Command Structure + +``` +actionkit [METHOD] RESOURCE [PARAMS...] +``` + +- **METHOD**: Optional HTTP method (get, post, patch, put, delete, search). Defaults to `get`. +- **RESOURCE**: The ActionKit resource type (users, orders, donations, etc.) +- **PARAMS**: Key-value pairs in the format `key=value` + +## Available Resources + +- `users` / `user` +- `orders` / `order` +- `donations` / `donationaction` / `donationactions` +- `campaigns` / `campaign` +- `petitions` / `petition` +- `transactions` / `transaction` +- `orderrecurring` +- `languages` / `language` +- `lists` / `list` +- `groups` / `group` +- `uploads` / `upload` +- `signuppages` / `signuppage` +- `signupactions` / `signupaction` +- `donationpages` / `donationpage` +- `genericpages` / `genericpage` +- `genericactions` / `genericaction` +- `multilingualcampaigns` / `multilingualcampaign` +- `sql` + +## Examples + +### GET Operations + +```bash +# Get a user by ID (method defaults to 'get') +actionkit users id=123 + +# Explicit GET +actionkit get users id=123 + +# Alternative syntax with ID in path +actionkit users/123 + +# Get with additional parameters +actionkit users id=123 limit=10 +``` + +### SEARCH Operations + +```bash +# Search users by email +actionkit search users email=test@example.com + +# Search with multiple parameters +actionkit search orders status=completed limit=50 + +# Search donations +actionkit search donations page=donate-page +``` + +### POST Operations (Create) + +```bash +# Create a new user +actionkit post users email=newuser@example.com first_name=John last_name=Doe + +# The CLI will automatically fetch and display the created resource +``` + +### PATCH Operations (Update) + +```bash +# Update a user by ID in path +actionkit patch users/123 first_name=Jane + +# Update using id parameter +actionkit patch users id=123 last_name=Smith + +# The CLI will automatically fetch and display the updated resource +``` + +### PUT Operations (Full Update) + +```bash +# Full update of a resource +actionkit put users/123 email=updated@example.com first_name=Jane last_name=Doe +``` + +### DELETE Operations + +```bash +# Delete by ID in path +actionkit delete users/123 + +# Delete using id parameter +actionkit delete users id=123 +``` + +## Parameter Types + +The CLI automatically converts parameter values to appropriate types: + +```bash +# Integers +actionkit users id=123 # 123 as int + +# Booleans +actionkit users active=true # true as boolean +actionkit users archived=false # false as boolean + +# Null values +actionkit users middle_name=null # null/None + +# Floats +actionkit orders amount=25.50 # 25.50 as float + +# Strings +actionkit users name="John Doe" # String (quotes optional) +``` + +## Output Options + +```bash +# Default JSON output (pretty-printed) +actionkit users id=123 + +# Raw output without formatting +actionkit --raw users id=123 + +# Specify format explicitly +actionkit --format json users id=123 +``` + +## Error Handling + +The CLI provides helpful error messages: + +```bash +# Unknown resource +$ actionkit fakeresource id=1 +Error: Unknown resource 'fakeresource' +Available resources: campaign, campaigns, donation, ... + +# Missing parameters +$ actionkit patch users +Error: PATCH requires a resource ID + +# Connection errors +$ actionkit users id=123 +Error connecting to ActionKit: Oops, I couldn't find login information... +``` + +## Common Workflows + +### Finding a user and updating their information + +```bash +# Search for user +actionkit search users email=user@example.com + +# Update user (using ID from search results) +actionkit patch users/456 phone=555-1234 +``` + +### Listing recent donations + +```bash +# Search donations with filters +actionkit search donations created_at__gte=2024-01-01 limit=100 +``` + +### Checking order details + +```bash +# Get specific order +actionkit orders id=789 + +# Search orders by status +actionkit search orders status=completed +``` + +## Tips + +1. **Use tab completion**: Most shells support command completion after installing the package +2. **Combine with jq**: Pipe output to `jq` for advanced JSON processing + ```bash + actionkit users id=123 | jq '.email' + ``` +3. **Save credentials**: Use `.envrc` with direnv to automatically load credentials per directory +4. **Check available resources**: Run `actionkit fakeresource` to see the full list of available resources diff --git a/actionkit/cli.py b/actionkit/cli.py new file mode 100644 index 0000000..d16c7b8 --- /dev/null +++ b/actionkit/cli.py @@ -0,0 +1,262 @@ +""" +Command-line interface for ActionKit API +""" +import json +import sys +from typing import Dict, Any + +import click + +from . import ActionKit + + +# Map of CLI resource names to ActionKit class attributes +RESOURCE_MAP = { + 'users': 'Users', + 'user': 'Users', + 'orders': 'Orders', + 'order': 'Orders', + 'orderrecurring': 'OrderRecurring', + 'donationaction': 'DonationAction', + 'donationactions': 'DonationAction', + 'donations': 'DonationAction', + 'donation': 'DonationAction', + 'campaigns': 'Campaigns', + 'campaign': 'Campaigns', + 'petitions': 'Petitions', + 'petition': 'Petitions', + 'languages': 'Languages', + 'language': 'Languages', + 'lists': 'Lists', + 'list': 'Lists', + 'groups': 'Groups', + 'group': 'Groups', + 'uploads': 'Uploads', + 'upload': 'Uploads', + 'transactions': 'Transactions', + 'transaction': 'Transactions', + 'sql': 'SQL', + 'signuppages': 'SignupPages', + 'signuppage': 'SignupPages', + 'signupactions': 'SignupActions', + 'signupaction': 'SignupActions', + 'genericpages': 'GenericPages', + 'genericpage': 'GenericPages', + 'genericactions': 'GenericActions', + 'genericaction': 'GenericActions', + 'donationpages': 'DonationPages', + 'donationpage': 'DonationPages', + 'multilingualcampaigns': 'MultilingualCampaigns', + 'multilingualcampaign': 'MultilingualCampaigns', +} + +# Valid HTTP methods +METHODS = ['get', 'post', 'patch', 'put', 'delete', 'search'] + + +def parse_params(params: tuple) -> Dict[str, Any]: + """ + Parse key=value parameters from command line. + + Examples: + id=123 -> {'id': '123'} + name=test status=active -> {'name': 'test', 'status': 'active'} + """ + parsed = {} + for param in params: + if '=' not in param: + click.echo(f"Error: Invalid parameter format '{param}'. Expected key=value", err=True) + sys.exit(1) + + key, value = param.split('=', 1) + + # Try to convert to appropriate type + if value.lower() == 'true': + value = True + elif value.lower() == 'false': + value = False + elif value.lower() == 'null' or value.lower() == 'none': + value = None + elif value.isdigit(): + value = int(value) + else: + # Try to parse as float + try: + value = float(value) + except ValueError: + # Keep as string + pass + + parsed[key] = value + + return parsed + + +def format_output(data: Any, format: str = 'json') -> str: + """Format output data for display.""" + if format == 'json': + return json.dumps(data, indent=2, default=str) + return str(data) + + +@click.command() +@click.argument('args', nargs=-1, required=True) +@click.option('--hostname', envvar='ACTIONKIT_HOSTNAME', help='ActionKit hostname') +@click.option('--username', envvar='ACTIONKIT_USERNAME', help='ActionKit username') +@click.option('--password', envvar='ACTIONKIT_PASSWORD', help='ActionKit password') +@click.option('--format', '-f', default='json', type=click.Choice(['json']), + help='Output format') +@click.option('--raw', is_flag=True, help='Output raw response without formatting') +def main(args, hostname, username, password, format, raw): + """ + ActionKit API command-line interface. + + Usage: + actionkit [METHOD] RESOURCE [PARAMS...] + + Examples: + actionkit get users id=123 + actionkit users id=123 (get is default) + actionkit search users email=test@example.com + actionkit post users email=new@example.com first_name=John + actionkit patch users/123 first_name=Jane + actionkit delete users/123 + + Methods: + get, post, patch, put, delete, search + + Resources: + users, orders, donations, campaigns, petitions, transactions, etc. + + Parameters: + Specified as key=value pairs + Special handling: id=N -> looks up resource by ID + """ + if len(args) < 1: + click.echo("Error: Must specify at least a resource", err=True) + click.echo("Usage: actionkit [METHOD] RESOURCE [PARAMS...]", err=True) + sys.exit(1) + + # Parse arguments + # Determine if first arg is a method or resource + method = 'get' + resource = None + params_start = 1 + + if args[0].lower() in METHODS: + method = args[0].lower() + if len(args) < 2: + click.echo("Error: Must specify a resource", err=True) + sys.exit(1) + resource = args[1] + params_start = 2 + else: + resource = args[0] + params_start = 1 + + # Parse parameters + params = parse_params(args[params_start:]) + + # Handle resource path (e.g., "users/123" or just "users") + resource_parts = resource.split('/') + resource_name = resource_parts[0].lower() + resource_id = resource_parts[1] if len(resource_parts) > 1 else None + + # Get the resource handler - check this before connecting + if resource_name not in RESOURCE_MAP: + click.echo(f"Error: Unknown resource '{resource_name}'", err=True) + click.echo(f"Available resources: {', '.join(sorted(set(RESOURCE_MAP.keys())))}", err=True) + sys.exit(1) + + # Connect to ActionKit + try: + ak = ActionKit(hostname=hostname, username=username, password=password) + except Exception as e: + click.echo(f"Error connecting to ActionKit: {e}", err=True) + sys.exit(1) + + handler_name = RESOURCE_MAP[resource_name] + handler = getattr(ak, handler_name) + + try: + result = None + + if method == 'get': + if resource_id: + # Get by ID from path + result = handler.get_by_id(resource_id, **params) + elif 'id' in params: + # Get by ID from params + resource_id = params.pop('id') + result = handler.get_by_id(resource_id, **params) + else: + # Get with params (might be a list or single item) + result = handler.get(**params) + + elif method == 'search': + result = handler.search(**params) + + elif method == 'post': + # POST creates a new resource + result = handler.post(json=params) + # If we got a resource_uri back, fetch the created object + if isinstance(result, str) and result.startswith('/rest/v1/'): + result = handler.get(result) + + elif method == 'patch': + if resource_id: + resource_uri = handler.get_resource_uri_from_id(resource_id) + elif 'id' in params: + resource_id = params.pop('id') + resource_uri = handler.get_resource_uri_from_id(resource_id) + else: + click.echo("Error: PATCH requires a resource ID", err=True) + sys.exit(1) + + result = handler.patch(resource_uri, params) + # Fetch the updated object + result = handler.get(resource_uri) + + elif method == 'put': + if resource_id: + resource_uri = handler.get_resource_uri_from_id(resource_id) + elif 'id' in params: + resource_id = params.pop('id') + resource_uri = handler.get_resource_uri_from_id(resource_id) + else: + click.echo("Error: PUT requires a resource ID", err=True) + sys.exit(1) + + result = handler.put(resource_uri, params) + # Fetch the updated object + result = handler.get(resource_uri) + + elif method == 'delete': + if resource_id: + resource_uri = handler.get_resource_uri_from_id(resource_id) + elif 'id' in params: + resource_id = params.pop('id') + resource_uri = handler.get_resource_uri_from_id(resource_id) + else: + click.echo("Error: DELETE requires a resource ID", err=True) + sys.exit(1) + + result = handler.delete(resource_uri) + result = {'deleted': True, 'resource_uri': resource_uri} + + # Output result + if raw: + click.echo(result) + else: + click.echo(format_output(result, format)) + + except Exception as e: + click.echo(f"Error executing {method} on {resource}: {e}", err=True) + import traceback + if '--debug' in sys.argv: + traceback.print_exc() + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/actionkit/users.py b/actionkit/users.py index 5ec8e7e..43197b1 100644 --- a/actionkit/users.py +++ b/actionkit/users.py @@ -24,7 +24,7 @@ def uri(self, id=None): return f"{self.resource_name}/{id or ''}" def id(self, uri): - m = re.search(f"/{self.resource_name}/(\d+)", uri) + m = re.search(r'/user/(\d+)', uri) if m: return m[1] else: diff --git a/setup.py b/setup.py index 5e0df61..95d42ef 100644 --- a/setup.py +++ b/setup.py @@ -5,5 +5,10 @@ version="0.3.5", packages=find_packages(), include_package_data=True, - install_requires=["requests", "requests-toolbelt"], + install_requires=["requests", "requests-toolbelt", "click>=8.0"], + entry_points={ + "console_scripts": [ + "actionkit=actionkit.cli:main", + ], + }, )