From e586e3a5a90f8c917be6d596f9fa5a5723f553b1 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sat, 19 Sep 2026 18:36:42 +0200 Subject: [PATCH] fix(examples): isolate Python API demos from caller queues --- examples/python-api/01_basic_usage.py | 19 ++- examples/python-api/02_ticket_management.py | 30 +++-- examples/python-api/03_integration.py | 9 +- examples/python-api/03_integration_simple.py | 5 +- examples/python-api/04_advanced_filtering.py | 22 +++- examples/python-api/04_analytics_simple.py | 62 ++++----- examples/python-api/05_dsl_usage.py | 125 +++++-------------- examples/python-api/README.md | 40 +++--- examples/python-api/demo_store.py | 42 +++++++ examples/python-api/run_all.sh | 41 ++---- planfile/extensions/__init__.py | 27 ++-- project/ticket-156/README.md | 22 ++++ project/ticket-156/intent.json | 94 ++++++++++++++ tests/test_python_api_examples.py | 80 ++++++++++++ 14 files changed, 404 insertions(+), 214 deletions(-) create mode 100644 examples/python-api/demo_store.py create mode 100644 project/ticket-156/README.md create mode 100644 project/ticket-156/intent.json create mode 100644 tests/test_python_api_examples.py diff --git a/examples/python-api/01_basic_usage.py b/examples/python-api/01_basic_usage.py index 4b6b477..d0b69ef 100755 --- a/examples/python-api/01_basic_usage.py +++ b/examples/python-api/01_basic_usage.py @@ -8,20 +8,24 @@ 3. Use the quick_ticket helper """ +from demo_store import isolated_demo + from planfile import Planfile, quick_ticket +@isolated_demo def example_1_basic_initialization(): """Initialize planfile with auto-discovery.""" print("=== Example 1: Basic Initialization ===\n") # Auto-discover .planfile/ in current or parent directories pf = Planfile.auto_discover(".") - print(f"✓ Planfile initialized at: {pf.store.root}") - print(f" Store path: {pf.store.planfile_dir}") + print(f"✓ Planfile initialized at: {pf.store.project_dir}") + print(f" Store path: {pf.store.base_dir}") print() +@isolated_demo def example_2_create_ticket(): """Create a ticket programmatically.""" print("=== Example 2: Creating a Ticket ===\n") @@ -30,7 +34,7 @@ def example_2_create_ticket(): # Create a simple ticket ticket = pf.create_ticket( - title="Fix authentication bug", + name="Fix authentication bug", description="Users cannot login with OAuth provider", priority="high", status="open", @@ -39,20 +43,21 @@ def example_2_create_ticket(): ) print(f"✓ Created ticket: {ticket.id}") - print(f" Title: {ticket.title}") + print(f" Title: {ticket.name}") print(f" Priority: {ticket.priority}") print(f" Status: {ticket.status}") print(f" Sprint: {ticket.sprint}") print() +@isolated_demo def example_3_quick_ticket(): """Use quick_ticket helper for one-off ticket creation.""" print("=== Example 3: Quick Ticket Helper ===\n") # One-liner for tools and scripts ticket = quick_ticket( - title="Production alert: High memory usage on prod-01", + name="Production alert: High memory usage on prod-01", tool="monitoring-system", priority="critical", context={"server": "prod-01", "metric": "memory", "threshold": "90%", "duration": "5m"}, @@ -64,6 +69,7 @@ def example_3_quick_ticket(): print() +@isolated_demo def example_4_list_tickets(): """List and filter tickets.""" print("=== Example 4: Listing Tickets ===\n") @@ -75,7 +81,7 @@ def example_4_list_tickets(): print(f"Found {len(tickets)} tickets in current sprint:\n") for t in tickets[:5]: # Show first 5 - print(f" {t.id}: {t.title} [{t.status}]") + print(f" {t.id}: {t.name} [{t.status}]") if len(tickets) > 5: print(f" ... and {len(tickets) - 5} more") @@ -86,6 +92,7 @@ def example_4_list_tickets(): print() +@isolated_demo def main(): """Run all examples.""" print("\n" + "=" * 60) diff --git a/examples/python-api/02_ticket_management.py b/examples/python-api/02_ticket_management.py index 489955f..8878129 100755 --- a/examples/python-api/02_ticket_management.py +++ b/examples/python-api/02_ticket_management.py @@ -9,9 +9,12 @@ - Delete """ +from demo_store import isolated_demo + from planfile import Planfile +@isolated_demo def example_create_tickets(): """Create multiple tickets.""" print("=== Creating Tickets ===\n") @@ -20,7 +23,7 @@ def example_create_tickets(): # Create different types of tickets (using labels to categorize) bug = pf.create_ticket( - title="Login button not working on mobile", + name="Login button not working on mobile", description="Users report login button is unresponsive on iOS Safari", priority="high", labels=["bug", "mobile", "ios"], @@ -29,7 +32,7 @@ def example_create_tickets(): print(f"✓ Bug ticket: {bug.id}") feature = pf.create_ticket( - title="Add dark mode support", + name="Add dark mode support", description="Implement system-wide dark mode toggle", priority="medium", labels=["feature", "ui", "accessibility"], @@ -37,7 +40,7 @@ def example_create_tickets(): print(f"✓ Feature ticket: {feature.id}") docs = pf.create_ticket( - title="Update API documentation", + name="Update API documentation", description="Add examples for new endpoints", priority="low", labels=["docs", "api"], @@ -47,6 +50,7 @@ def example_create_tickets(): return [bug.id, feature.id, docs.id] +@isolated_demo def example_read_tickets(ticket_ids): """Read/retrieve tickets.""" print("=== Reading Tickets ===\n") @@ -57,7 +61,7 @@ def example_read_tickets(ticket_ids): ticket = pf.get_ticket(ticket_ids[0]) print("Single ticket lookup:") print(f" ID: {ticket.id}") - print(f" Title: {ticket.title}") + print(f" Title: {ticket.name}") print(f" Priority: {ticket.priority}") print(f" Status: {ticket.status}") print() @@ -67,6 +71,7 @@ def example_read_tickets(ticket_ids): print(f"Total tickets: {len(all_tickets)}\n") +@isolated_demo def example_update_tickets(ticket_ids): """Update ticket properties.""" print("=== Updating Tickets ===\n") @@ -74,7 +79,7 @@ def example_update_tickets(ticket_ids): pf = Planfile.auto_discover(".") # Update status - updated = pf.update_ticket(ticket_ids[0], status="in_progress", comment="Started investigation") + updated = pf.update_ticket(ticket_ids[0], status="in_progress") print(f"✓ Updated {updated.id}: status → {updated.status}") # Update multiple fields @@ -88,6 +93,7 @@ def example_update_tickets(ticket_ids): print() +@isolated_demo def example_bulk_operations(): """Bulk create tickets from external data.""" print("=== Bulk Operations ===\n") @@ -97,20 +103,20 @@ def example_bulk_operations(): # Import from external source (e.g., Jira, CSV, monitoring alerts) external_data = [ { - "title": "Database connection timeout", + "name": "Database connection timeout", "description": "Intermittent timeouts during peak hours", "priority": "critical", "labels": ["bug"], "source_id": "JIRA-1234", }, { - "title": "Implement user search", + "name": "Implement user search", "description": "Add fuzzy search to user directory", "priority": "medium", "labels": ["feature"], }, { - "title": "Refactor auth module", + "name": "Refactor auth module", "description": "Reduce code complexity in auth.py", "priority": "low", "labels": ["chore"], @@ -124,12 +130,13 @@ def example_bulk_operations(): print(f"✓ Bulk created {len(created)} tickets:\n") for t in created: - print(f" {t.id}: {t.title} [{t.priority}] - Labels: {t.labels}") + print(f" {t.id}: {t.name} [{t.priority}] - Labels: {t.labels}") print() return [t.id for t in created] +@isolated_demo def example_delete_and_move(ticket_ids): """Delete and move tickets.""" print("=== Delete and Move ===\n") @@ -137,7 +144,7 @@ def example_delete_and_move(ticket_ids): pf = Planfile.auto_discover(".") # Move ticket to different sprint - moved = pf.store.move_ticket(ticket_ids[0], to_sprint="backlog") + pf.store.move_ticket(ticket_ids[0], to_sprint="backlog") print(f"✓ Moved {ticket_ids[0]} to backlog\n") # Delete a ticket (use with caution) @@ -145,6 +152,7 @@ def example_delete_and_move(ticket_ids): # print(f"✓ Deleted {ticket_ids[-1]}\n") +@isolated_demo def main(): """Run all examples.""" print("\n" + "=" * 60) @@ -154,7 +162,7 @@ def main(): ticket_ids = example_create_tickets() example_read_tickets(ticket_ids) example_update_tickets(ticket_ids) - bulk_ids = example_bulk_operations() + example_bulk_operations() example_delete_and_move(ticket_ids) print("=" * 60) diff --git a/examples/python-api/03_integration.py b/examples/python-api/03_integration.py index a91a82a..e7d0920 100755 --- a/examples/python-api/03_integration.py +++ b/examples/python-api/03_integration.py @@ -8,10 +8,13 @@ - Track metrics and alerts as tickets """ +from demo_store import isolated_demo + from planfile import quick_ticket from planfile.extensions import TicketLogger +@isolated_demo def example_cli_tool_integration(): """Show integration with a CLI tool.""" print("=== Example: CLI Tool Integration ===\n") @@ -32,6 +35,7 @@ def example_cli_tool_integration(): print() +@isolated_demo def example_monitoring_integration(): """Monitoring system integration.""" print("=== Example: Monitoring Integration ===\n") @@ -55,6 +59,7 @@ def example_monitoring_integration(): print() +@isolated_demo def example_ci_pipeline_integration(): """CI pipeline failure tracking.""" print("=== Example: CI Pipeline Integration ===\n") @@ -84,6 +89,7 @@ def example_ci_pipeline_integration(): print() +@isolated_demo def example_custom_decorator(): """Decorator for automatic error tracking.""" print("=== Example: Error Tracking Decorator ===\n") @@ -97,7 +103,7 @@ def wrapper(*args, **kwargs): return func(*args, **kwargs) except Exception as e: quick_ticket( - title=f"[{tool_name}] {func.__name__} failed: {str(e)[:40]}", + name=f"[{tool_name}] {func.__name__} failed: {str(e)[:40]}", tool=tool_name, priority="high", description=f"Function {func.__name__} raised {type(e).__name__}", @@ -138,6 +144,7 @@ def process_data(data): print() +@isolated_demo def main(): """Run all examples.""" print("\n" + "=" * 60) diff --git a/examples/python-api/03_integration_simple.py b/examples/python-api/03_integration_simple.py index 56323b8..7368482 100644 --- a/examples/python-api/03_integration_simple.py +++ b/examples/python-api/03_integration_simple.py @@ -6,9 +6,12 @@ AFTER: 40 lines (using native API) """ +from demo_store import isolated_demo + from planfile.extensions import TicketLogger +@isolated_demo def main(): """Run simplified integration examples.""" print("\n" + "=" * 60) @@ -40,7 +43,7 @@ def risky_operation(): try: risky_operation() - except: + except ValueError: print(" Error tracked as ticket") print("\n" + "=" * 60) diff --git a/examples/python-api/04_advanced_filtering.py b/examples/python-api/04_advanced_filtering.py index 1d119c2..4e49144 100755 --- a/examples/python-api/04_advanced_filtering.py +++ b/examples/python-api/04_advanced_filtering.py @@ -8,9 +8,12 @@ - Export filtered results """ +from demo_store import isolated_demo + from planfile import Planfile +@isolated_demo def example_basic_filtering(): """Basic ticket filtering.""" print("=== Basic Filtering ===\n") @@ -34,6 +37,7 @@ def example_basic_filtering(): print() +@isolated_demo def example_combined_filters(): """Combined filter criteria.""" print("=== Combined Filters ===\n") @@ -44,10 +48,11 @@ def example_combined_filters(): urgent_tickets = pf.list_tickets(status="open", priority="high", sprint="current") print(f"Urgent tickets in current sprint: {len(urgent_tickets)}") for t in urgent_tickets[:3]: - print(f" {t.id}: {t.title}") + print(f" {t.id}: {t.name}") print() +@isolated_demo def example_search_by_labels(): """Search by labels and tags.""" print("=== Label-based Search ===\n") @@ -71,6 +76,7 @@ def example_search_by_labels(): print() +@isolated_demo def example_export_filtered(): """Export filtered results to various formats.""" print("=== Export Filtered Results ===\n") @@ -78,24 +84,28 @@ def example_export_filtered(): pf = Planfile.auto_discover(".") # Get high priority open tickets for sprint planning - sprint_tickets = pf.list_tickets(sprint="current", status="open", priority=["high", "critical"]) + sprint_tickets = [ + ticket for ticket in pf.list_tickets(sprint="current", status="open") + if ticket.priority in {"high", "critical"} + ] # Export to CSV format print("CSV Export:") print("id,title,priority,labels") for t in sprint_tickets[:5]: labels_str = "|".join(t.labels) if t.labels else "none" - print(f"{t.id},{t.title[:30]},{t.priority},{labels_str}") + print(f"{t.id},{t.name[:30]},{t.priority},{labels_str}") print("\nMarkdown Export:") print("## Sprint Tickets (High/Critical Priority)\n") for t in sprint_tickets[:5]: - print(f"- **{t.id}** [{t.priority}] {t.title}") + print(f"- **{t.id}** [{t.priority}] {t.name}") if t.labels: print(f" - Labels: {', '.join(t.labels)}") print() +@isolated_demo def example_statistics(): """Generate ticket statistics.""" print("=== Ticket Statistics ===\n") @@ -131,8 +141,12 @@ def example_statistics(): print() +@isolated_demo def main(): """Run all examples.""" + Planfile.auto_discover(".").create_ticket( + name="Example authentication bug", priority="high", labels=["bug", "backend"] + ) print("\n" + "=" * 60) print("Planfile Python Library - Advanced Filtering") print("=" * 60 + "\n") diff --git a/examples/python-api/04_analytics_simple.py b/examples/python-api/04_analytics_simple.py index 0826cd0..d43cd7a 100644 --- a/examples/python-api/04_analytics_simple.py +++ b/examples/python-api/04_analytics_simple.py @@ -1,59 +1,47 @@ #!/usr/bin/env python3 -""" -Simplified filtering and analytics using native store methods. +"""Filtering and analytics using tickets returned by the public Python API.""" -BEFORE: 163 lines -AFTER: 45 lines (-72%) -""" +import csv +import io +import json +from collections import Counter + +from demo_store import isolated_demo from planfile import Planfile +@isolated_demo def main(): - """Run simplified analytics examples.""" - print("\n" + "=" * 60) - print("Simplified Analytics - Using Native API") - print("=" * 60 + "\n") - + """Summarize and export a small disposable dataset.""" pf = Planfile.auto_discover(".") + pf.create_ticket(name="Example authentication bug", priority="high", labels=["bug", "backend"]) + tickets = pf.list_tickets() - # 1. Get statistics with one call print("1. Ticket Statistics:") - stats = pf.store.stats() - print(f" Total: {stats['total']}") - print(f" By Status: {stats['by_status']}") - print(f" By Priority: {stats['by_priority']}") - print(" Top Labels: dict(list(stats['by_label'].items())[:5])") + print(f" Total: {len(tickets)}") + print(f" By Status: {dict(Counter(t.status for t in tickets))}") + print(f" By Priority: {dict(Counter(t.priority for t in tickets))}") + print(f" Top Labels: {Counter(label for t in tickets for label in t.labels).most_common(5)}") - # 2. Export to different formats print("\n2. Export Formats:") + csv_output = io.StringIO() + writer = csv.writer(csv_output) + writer.writerow(["id", "name", "priority"]) + writer.writerows((t.id, t.name, t.priority) for t in pf.list_tickets(sprint="current")) + print(csv_output.getvalue()) + for ticket in pf.list_tickets(status="open"): + print(f"- **{ticket.id}** {ticket.name}") + print(json.dumps([t.model_dump(mode="json") for t in pf.list_tickets(priority="high")])) - # CSV - csv_data = pf.store.export("csv", sprint="current") - print(f" CSV: {len(csv_data.split(chr(10)))} lines") - - # Markdown - md_data = pf.store.export("markdown", status="open") - print(f" Markdown: {len(md_data.split(chr(10)))} lines") - - # JSON - json_data = pf.store.export("json", priority="high") - print(f" JSON: {len(json_data)} chars") - - # 3. Full-text search print("\n3. Search:") - results = pf.store.search("authentication", fields=["title", "description"]) - print(f" Found '{len(results)}' tickets matching 'authentication'") + results = [t for t in tickets if "authentication" in f"{t.name} {t.description or ''}".lower()] + print(f" Found {len(results)} tickets matching 'authentication'") - # 4. Filter by labels print("\n4. Label Filtering:") bugs = pf.list_tickets(sprint="all", labels=["bug"]) print(f" Tickets with 'bug' label: {len(bugs)}") - print("\n" + "=" * 60) - print("Done!") - print("=" * 60) - if __name__ == "__main__": main() diff --git a/examples/python-api/05_dsl_usage.py b/examples/python-api/05_dsl_usage.py index b309cbb..5815a3e 100644 --- a/examples/python-api/05_dsl_usage.py +++ b/examples/python-api/05_dsl_usage.py @@ -1,109 +1,48 @@ -"""DSL (Domain Specific Language) examples for planfile. +"""Parse DSL commands and execute local operations in a disposable store.""" -DSL allows natural language-like commands to operate on planfile YAML. -""" +from demo_store import isolated_demo from planfile import DSLExecutor, DSLParser -def example_basic_dsl(): - """Basic DSL command execution.""" - executor = DSLExecutor(project_path=".") +def run_checked(executor, command): + """Surface a DSL failure instead of printing a misleading successful exit.""" + result = executor.run(command) + if not result.ok: + raise RuntimeError(f"{command}: {result.error}") + print(result.message or result.data) + return result - # List all tickets in current sprint - result = executor.run("list tickets sprint=current") - print(result.ok, result.data, result.message) - # Create a new ticket - result = executor.run('create ticket "Fix login bug" priority=high sprint=1') - print(result.ok, result.data) - - # Update ticket status - result = executor.run("update ticket PLF-001 status=done") - print(result.ok, result.message) +@isolated_demo +def example_basic_dsl(): + """Create, update and read an actual ticket in the demonstration store.""" + executor = DSLExecutor(project_path=".") + run_checked(executor, "list tickets sprint=current") + created = run_checked(executor, 'create ticket "Fix login bug" priority=high sprint=current') + run_checked(executor, f"update ticket {created.data['id']} status=done") + run_checked(executor, "query tickets where status=done priority=high") + run_checked(executor, "export format=yaml") +@isolated_demo def example_parser_only(): - """Parse DSL commands without executing.""" + """Parse a command without executing it.""" parser = DSLParser() + command = parser.parse('create ticket "New feature" priority=high labels=backend,auth') + print(f"Verb: {command.verb}") + print(f"Object: {command.object_type}") + print(f"Target: {command.target}") + print(f"Params: {command.params}") - cmd = parser.parse('create ticket "New feature" priority=high labels=backend,auth') - print(f"Verb: {cmd.verb}") - print(f"Object: {cmd.object_type}") - print(f"Target: {cmd.target}") - print(f"Params: {cmd.params}") - - cmd = parser.parse("list tickets sprint=current status=open") - print(f"Filters: {cmd.params}") - - -def example_batch_operations(): - """Batch ticket operations using DSL.""" - executor = DSLExecutor(project_path=".") - - # Mark multiple tickets as done - for ticket_id in ["PLF-001", "PLF-002", "PLF-003"]: - result = executor.run(f"done ticket {ticket_id}") - print(f"{ticket_id}: {result.ok}") - - # Move all high-priority tickets to sprint 2 - result = executor.run("query tickets where priority=high") - if result.ok: - for ticket in result.data: - result = executor.run(f"move ticket {ticket['id']} to=2") - - -def example_sprint_management(): - """Sprint operations via DSL.""" - executor = DSLExecutor(project_path=".") - - # List sprints - result = executor.run("list sprints") - print(result.data) - # Add new sprint - result = executor.run('add sprint "Sprint 4" days=14') - print(result.message) - - -def example_validation_sync(): - """Validation and sync via DSL.""" - executor = DSLExecutor(project_path=".") - - # Validate tickets - result = executor.run("validate") - print(result.message) - - # Sync to GitHub - result = executor.run("sync github") - print(result.message) - - # Sync all integrations - result = executor.run("sync all") - print(result.message) - - -def example_query_and_export(): - """Query tickets and export data.""" - executor = DSLExecutor(project_path=".") - - # Query with filters - result = executor.run("query tickets where status=open priority=high") - print(result.data) - - # Export to YAML - result = executor.run("export format=yaml") - print(result.data) +@isolated_demo +def main(): + """Run local examples; remote sync and strategy validation need explicit setup.""" + print("DSL Examples") + example_parser_only() + example_basic_dsl() if __name__ == "__main__": - print("DSL Examples") - print("=" * 40) - - # Uncomment to run examples - # example_basic_dsl() - # example_parser_only() - # example_batch_operations() - # example_sprint_management() - # example_validation_sync() - # example_query_and_export() + main() diff --git a/examples/python-api/README.md b/examples/python-api/README.md index dfac9d2..7b4ca75 100644 --- a/examples/python-api/README.md +++ b/examples/python-api/README.md @@ -1,25 +1,27 @@ # Python Library API Examples -Examples of using planfile as a Python library in your applications. +Run these synchronous demonstrations with Planfile installed in your Python environment: -## Files - -- `01_basic_usage.py` - Basic Planfile class usage -- `02_ticket_management.py` - CRUD operations on tickets -- `03_integration.py` - Integrating planfile into existing tools -- `04_bulk_operations.py` - Bulk ticket operations -- `05_filtering.py` - Advanced ticket filtering -- `05_dsl_usage.py` - DSL (Domain Specific Language) usage examples - -# Navigate to examples directory -cd examples/python-api +```bash +python examples/python-api/01_basic_usage.py +bash examples/python-api/run_all.sh +``` -# Run individual examples -python 01_basic_usage.py -python 02_ticket_management.py +Each script creates a fresh temporary `.planfile` store, then removes it on exit. +The runner does not install packages or synchronize external integrations. +`demo_store.py` also isolates calls to the decorated example functions when imported. +Nested examples share the current demonstration store; separate runs start fresh. +The helper temporarily changes the process working directory and is intended for +these synchronous examples, not concurrent application code. -## Prerequisites +- `01_basic_usage.py`: initialization, ticket creation and the `quick_ticket` helper. +- `02_ticket_management.py`: create, read, update, bulk import and sprint moves. +- `03_integration.py` and `03_integration_simple.py`: logger and error decorators. +- `04_advanced_filtering.py` and `04_analytics_simple.py`: filtering and exports. +- `05_dsl_usage.py`: parsing and local DSL operations. -```bash -pip install planfile -``` +Select a specific interpreter with `PYTHON=/path/to/python bash examples/python-api/run_all.sh`. +To retain real tickets in an application, initialize Planfile for an explicit project +and use its current API (`name` for ticket names). Do not copy the disposable-store +wrapper into application lifecycle code. Existing project queues are never cleaned +or migrated by these demonstrations. diff --git a/examples/python-api/demo_store.py b/examples/python-api/demo_store.py new file mode 100644 index 0000000..71f3ced --- /dev/null +++ b/examples/python-api/demo_store.py @@ -0,0 +1,42 @@ +"""Disposable stores for the synchronous Python API demonstrations.""" + +import os +from contextlib import contextmanager +from contextvars import ContextVar +from functools import wraps +from pathlib import Path +from tempfile import TemporaryDirectory + +from planfile import Planfile + +_active_demo = ContextVar("active_planfile_demo", default=None) + + +@contextmanager +def demo_store(): + """Keep nested examples in one temporary store; restore cwd even on failure.""" + if _active_demo.get() is not None: + yield _active_demo.get() + return + original = Path.cwd() + with TemporaryDirectory(prefix="planfile-demo-") as directory: + root = Path(directory).resolve() + # Initialize explicitly before auto-discovery can inspect any parent. + Planfile(str(root)) + reset_marker = _active_demo.set(root) + try: + os.chdir(root) + print(f"Disposable demo store: {root}") + yield root + finally: + os.chdir(original) + _active_demo.reset(reset_marker) + + +def isolated_demo(function): + """Run a CLI entry point or imported example in a disposable local store.""" + @wraps(function) + def wrapped(*args, **kwargs): + with demo_store(): + return function(*args, **kwargs) + return wrapped diff --git a/examples/python-api/run_all.sh b/examples/python-api/run_all.sh index 17c01cd..327f6ef 100755 --- a/examples/python-api/run_all.sh +++ b/examples/python-api/run_all.sh @@ -1,30 +1,13 @@ -#!/bin/bash -# Run all Python API examples - -set -e - -echo "==========================================" -echo "Planfile Python API Examples" -echo "==========================================" -echo - -cd "$(dirname "$0")" - -# Check if planfile is installed -if ! python3 -c "import planfile" 2>/dev/null; then - echo "Installing planfile..." - pip install planfile +#!/usr/bin/env bash +# Run local demonstrations; each script initializes and removes its own store. +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "$0")" && pwd)" +PYTHON="${PYTHON:-python3}" +if ! "$PYTHON" -c "import planfile"; then + echo "Install planfile in your chosen Python environment before running examples." >&2 + exit 1 fi - -echo "Running examples..." -echo - -python3 01_basic_usage.py -python3 02_ticket_management.py -python3 03_integration.py -python3 04_advanced_filtering.py - -echo -echo "==========================================" -echo "All examples completed!" -echo "==========================================" +for script in "$SCRIPT_DIR"/[0-9]*.py; do + "$PYTHON" "$script" +done +echo "All examples completed; disposable stores removed." diff --git a/planfile/extensions/__init__.py b/planfile/extensions/__init__.py index 7e1b51f..a9911a4 100644 --- a/planfile/extensions/__init__.py +++ b/planfile/extensions/__init__.py @@ -1,8 +1,9 @@ """Extensions for planfile - logger, analytics, and utilities.""" import traceback +from collections.abc import Callable from datetime import datetime -from typing import Any, Callable +from typing import Any from planfile import Planfile, Ticket, TicketSource @@ -10,27 +11,27 @@ class TicketLogger: """ Logger that creates tickets for errors, warnings, and alerts. - + Example: logger = TicketLogger("my-tool") - + # Log error as ticket logger.error("Database connection failed", context={"db": "prod"}) - + # Log metric alert logger.metric_alert("CPU", 95, threshold=90) - + # Use as decorator @logger.catch_errors def risky_function(): ... """ - + def __init__(self, tool_name: str, auto_create: bool = True): self.tool_name = tool_name self.pf = Planfile.auto_discover() self.auto_create = auto_create - + def error( self, message: str, @@ -44,25 +45,25 @@ def error( ctx["exception"] = str(exception) ctx["exception_type"] = type(exception).__name__ ctx["traceback"] = traceback.format_exc() - + return self.pf.create_ticket( - title=f"[{self.tool_name}] {message[:80]}", + name=f"[{self.tool_name}] {message[:80]}", description=message, priority=priority, source=TicketSource(tool=self.tool_name, context=ctx), labels=["error", "auto-generated"] ) - + def warning(self, message: str, context: dict = None) -> Ticket: """Create warning ticket.""" return self.pf.create_ticket( - title=f"[{self.tool_name}] {message[:80]}", + name=f"[{self.tool_name}] {message[:80]}", description=message, priority="medium", source=TicketSource(tool=self.tool_name, context=context or {}), labels=["warning", "auto-generated"] ) - + def metric_alert( self, metric: str, @@ -88,7 +89,7 @@ def metric_alert( ), labels=["alert", "metric", "auto-generated"] ) - + def catch_errors(self, func: Callable) -> Callable: """Decorator to auto-log function errors as tickets.""" def wrapper(*args, **kwargs): diff --git a/project/ticket-156/README.md b/project/ticket-156/README.md new file mode 100644 index 0000000..63854c1 --- /dev/null +++ b/project/ticket-156/README.md @@ -0,0 +1,22 @@ +# Ticket 156: Isolated Python API demonstrations + +- **ID**: ticket-156 +- **Owner**: codex-monag-continuation-20260919 +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-09-19 +- **Planfile**: PLF-081 +- **Issue**: https://github.com/semcod/planfile/issues/165 + +## Goal and scope + +SESSION_EXECUTION_AUTHORIZATION: user requested verified MONAG/Planfile fixes and publication, then continued execution. Fix only Python API demonstrations and the TicketLogger name argument they exercise. Ownership prerequisite PLF-082 was published in PR #167. Historical sample-like tickets remain untouched; their authorship is not established. + +## Acceptance criteria + +- [x] AC-01: Every executable Python API example uses a disposable queue and preserves caller files, including on failure. +- [ ] AC-02: Examples and logger calls match the supported API, regression/full tests and governed protected publication succeed. + +## Verification + +Ten regression tests pass, including subprocess execution of all seven examples and the shell runner from a real sentinel project, exception cleanup, and logger persistence. Full suite: 701 passed, 6 skipped. Ruff on the changed scope and governance passed. Protected publication receipts are recorded in the delivery checkpoint; GitHub issue #165 tracks completion. diff --git a/project/ticket-156/intent.json b/project/ticket-156/intent.json new file mode 100644 index 0000000..68f35c9 --- /dev/null +++ b/project/ticket-156/intent.json @@ -0,0 +1,94 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-156", + "summary": "Isolate Python API demos from real queues and align supported API", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "examples/python-api/**", + "planfile/extensions/__init__.py", + "tests/test_python_api_examples.py", + "project/ticket-156/**" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "45546de9ed645a2e92c1f9ff8c855a645f0c3c57", + "targetBranch": "main", + "outcome": "Run every executable Python API demonstration in a disposable local queue without changing the caller project; supported TicketLogger operations succeed", + "nonGoals": [ + "No removal or attribution of historical sample tickets", + "No remote sync or changes to real project queues", + "No changes to other example directories" + ], + "complexity": "L", + "estimatedMinutes": 60, + "budgets": { + "maxImplementationFiles": 15, + "maxAffectedComponents": 3, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Keep disposable cwd lifecycle in an example-only helper so auto-discovery, quick_ticket and TicketLogger use the same fresh local store", + "components": [ + { + "name": "examples", + "paths": [ + "examples/python-api/**" + ] + }, + { + "name": "logger", + "paths": [ + "planfile/extensions/__init__.py" + ] + }, + { + "name": "regression", + "paths": [ + "tests/test_python_api_examples.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the bounded follow-up commit through protected publication." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest -q tests/test_python_api_examples.py" + ], + "evidence": "Actual example subprocesses preserve sentinel caller queue and restore cwd on error" + }, + { + "criterion": "AC-02", + "commands": [ + "python -m pytest -q", + "python -m ruff check --no-respect-gitignore examples/python-api planfile/extensions/__init__.py tests/test_python_api_examples.py", + "./project/governance-check.sh --actor agent" + ], + "evidence": "Full suite and scope validation pass; exact-head protected CI before merge" + } + ] + } +} diff --git a/tests/test_python_api_examples.py b/tests/test_python_api_examples.py new file mode 100644 index 0000000..66ac4c9 --- /dev/null +++ b/tests/test_python_api_examples.py @@ -0,0 +1,80 @@ +"""Executable demos must not write into their caller's real project queue.""" + +import importlib.util +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from planfile import Planfile + +ROOT = Path(__file__).resolve().parents[1] +EXAMPLES = ROOT / "examples" / "python-api" +SCRIPTS = sorted(path.name for path in EXAMPLES.glob("[0-9]*.py")) + + +def snapshot(root): + return {str(p.relative_to(root)): p.read_bytes() for p in root.rglob("*") if p.is_file()} + + +@pytest.mark.parametrize("script", [*SCRIPTS, "run_all.sh"]) +def test_executable_demo_preserves_caller_project(tmp_path, script): + project = tmp_path / "real-project" + pf = Planfile(str(project)) + pf.create_ticket(name="Keep real work", priority="critical") + nested = project / "nested" + nested.mkdir() + scratch = project / "temporary-directories" + scratch.mkdir() + before = snapshot(project) + env = dict(os.environ, PYTHONPATH=str(ROOT), PYTHON=sys.executable, + PYTHONDONTWRITEBYTECODE="1", TMPDIR=str(scratch)) + command = ["bash" if script.endswith(".sh") else sys.executable, str(EXAMPLES / script)] + result = subprocess.run(command, cwd=nested, env=env, text=True, capture_output=True, timeout=60) + assert result.returncode == 0, result.stdout + result.stderr + assert "Disposable demo store:" in result.stdout + assert snapshot(project) == before + assert list(scratch.iterdir()) == [] + assert not (nested / ".planfile").exists() + + +def test_demo_failure_restores_cwd_and_removes_store(tmp_path, monkeypatch): + spec = importlib.util.spec_from_file_location("demo_store_under_test", EXAMPLES / "demo_store.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.chdir(tmp_path) + with pytest.raises(RuntimeError, match="demo failure"): + with module.demo_store() as root: + assert Path.cwd() == root + with module.demo_store() as nested: + assert nested == root + Planfile.auto_discover().create_ticket(name="Temporary ticket") + raise RuntimeError("demo failure") + assert Path.cwd() == tmp_path + assert not root.exists() + assert not (tmp_path / ".planfile").exists() + with module.demo_store() as second: + assert second != root + assert Planfile.auto_discover().list_tickets() == [] + + +def test_logger_persists_error_warning_and_reraises(tmp_path, monkeypatch): + from planfile.extensions import TicketLogger + + monkeypatch.chdir(tmp_path) + logger = TicketLogger("example-test") + error = logger.error("Example error", context={"operation": "demo"}) + warning = logger.warning("Example warning") + assert error.name == "[example-test] Example error" + assert warning.name == "[example-test] Example warning" + assert logger.pf.get_ticket(error.id).source.context["operation"] == "demo" + + @logger.catch_errors + def fail(): + raise ValueError("expected failure") + + with pytest.raises(ValueError, match="expected failure"): + fail() + assert len(logger.pf.list_tickets()) == 3