diff --git a/.claude/hooks/post-edit-lint.sh b/.claude/hooks/post-edit-lint.sh new file mode 100755 index 0000000..b1a972a --- /dev/null +++ b/.claude/hooks/post-edit-lint.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# PostToolUse (Edit|Write|MultiEdit): record touched file for batch processing in Stop. +# Must be <50ms. No linting, no formatting, no git calls. +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +INPUT=$(cat) +FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') + +# Only track .py files, skip vendored/generated paths +if [[ "$FILE" == *.py ]] && [[ "$FILE" != *__pycache__* ]] && [[ "$FILE" != *.egg-info* ]] && [[ "$FILE" != */dist/* ]] && [[ "$FILE" != */build/* ]]; then + TRACKER="$CLAUDE_PROJECT_DIR/.claude/tmp/edited-py-files-${CLAUDE_HOOK_SESSION_ID:-default}" + mkdir -p "$CLAUDE_PROJECT_DIR/.claude/tmp" + echo "$FILE" >> "$TRACKER" + sort -u "$TRACKER" -o "$TRACKER" +fi + +exit 0 diff --git a/.claude/hooks/post-stop-validate.sh b/.claude/hooks/post-stop-validate.sh new file mode 100755 index 0000000..59648b5 --- /dev/null +++ b/.claude/hooks/post-stop-validate.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# Stop: batch-lint touched files, run test suite (~0.13s). +# Python has no autoformatter configured, so lint + tests only. +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +TRACKER="$CLAUDE_PROJECT_DIR/.claude/tmp/edited-py-files-${CLAUDE_HOOK_SESSION_ID:-default}" + +ctx="" + +# Batch flake8 on tracked files (if any were edited) +if [[ -f "$TRACKER" ]]; then + files=$(cat "$TRACKER") + rm -f "$TRACKER" + + if [[ -n "$files" ]]; then + lint_out=$(echo "$files" | xargs python3 -m flake8 2>&1) || true + offenses=$(echo "$lint_out" | grep -E "^.+:[0-9]+:[0-9]+:" | head -20) + if [[ -n "$offenses" ]]; then + ctx+="flake8 offenses:\n$offenses\n" + fi + fi +fi + +# Always run tests - edits to tests or source both matter (~0.13s) +test_out=$(python3 -m unittest 2>&1) +test_exit=$? +if [[ $test_exit -ne 0 ]]; then + summary=$(echo "$test_out" | grep -E "^(FAILED|ERROR)" | tail -1) + failures=$(echo "$test_out" | grep -E "^(FAIL|ERROR):" | head -10) + ctx+="tests failed ($summary):\n$failures\n" +fi + +if [[ -n "$ctx" ]]; then + jq -n --arg ctx "$ctx" '{ + "hookSpecificOutput": { + "hookEventName": "Stop", + "additionalContext": $ctx + } + }' +fi + +exit 0 diff --git a/.claude/hooks/session-start-setup.sh b/.claude/hooks/session-start-setup.sh new file mode 100755 index 0000000..c2e9e9e --- /dev/null +++ b/.claude/hooks/session-start-setup.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# SessionStart: persist session ID, detect repo state. Must be <1s, no network. +cd "$CLAUDE_PROJECT_DIR" || exit 0 + +INPUT=$(cat) +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') + +# Persist session ID via CLAUDE_ENV_FILE so the edit tracker +# and stop hook share the same file path +if [[ -n "$CLAUDE_ENV_FILE" ]] && [[ -n "$SESSION_ID" ]]; then + echo "export CLAUDE_HOOK_SESSION_ID='$SESSION_ID'" >> "$CLAUDE_ENV_FILE" +fi + +ctx="" + +# Warn about uncommitted changes +dirty=$(git diff --name-only 2>/dev/null | head -5) +if [[ -n "$dirty" ]]; then + ctx+="Uncommitted changes:\n$dirty\n" +fi + +# Report current branch +branch=$(git branch --show-current 2>/dev/null) +if [[ -n "$branch" ]]; then + ctx+="Branch: $branch\n" +fi + +# Check if deps are installed +if ! python3 -c "import chartmogul" 2>/dev/null; then + ctx+="WARNING: chartmogul package not installed. Run: pip install -e .[testing]\n" +fi + +if [[ -n "$ctx" ]]; then + jq -n --arg ctx "$ctx" '{ + "hookSpecificOutput": { + "hookEventName": "SessionStart", + "additionalContext": $ctx + } + }' +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..cd37dbd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,76 @@ +{ + "permissions": { + "allow": [ + "Bash(pip install:*)", + "Bash(python -m unittest:*)", + "Bash(python -m pytest:*)", + "Bash(flake8:*)", + "Bash(coverage:*)", + "Bash(python setup.py sdist:*)", + "Bash(python --version)", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git branch:*)", + "Bash(git fetch:*)", + "Bash(git stash:*)", + "Bash(git checkout:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(git pull:*)", + "Bash(git cherry-pick:*)", + "Bash(git reset:*)", + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr diff:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr status:*)", + "Bash(gh run view:*)", + "Bash(gh run list:*)", + "Bash(gh run watch:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh release view:*)", + "Bash(gh release list:*)", + "Bash(gh repo view:*)", + "Bash(gh search:*)" + ], + "deny": [ + "Read(./.env*)" + ] + }, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "./.claude/hooks/session-start-setup.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "./.claude/hooks/post-edit-lint.sh" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "./.claude/hooks/post-stop-validate.sh" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 3b065ea..af46eb3 100644 --- a/.gitignore +++ b/.gitignore @@ -90,4 +90,21 @@ ENV/ # Rope project settings .ropeproject -/.idea \ No newline at end of file +/.idea + +# AI agent local/ephemeral data (shared config is tracked via negation) +.claude/* +!.claude/hooks/ +!.claude/settings.json +CLAUDE.local.md +.cursor/chat/ +.cursor/composer/ +.cursor/mcp.json +.windsurf/ +.cline/ +.continue/ +.junie/ +.aider* +.codex/ +.codeium/ +.copilot/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..095f347 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,55 @@ +# ChartMogul Python SDK + +Python SDK wrapping the ChartMogul API. Requires Python >= 3.10. Uses `requests` for HTTP with automatic retry and exponential backoff. + +## Commands + +- `pip install -e .[testing]` - install with test dependencies +- `python -m unittest` - run full test suite +- `python -m unittest test.api.test_customer` - run single test module +- `flake8 ./chartmogul` - lint source code +- `coverage run -m unittest && coverage xml -i --include='chartmogul/*'` - run tests with coverage + +Version is in `chartmogul/version.py`. + +## Architecture + +The class hierarchy is: `DataObject` (base with dynamic attributes and `__repr__`) -> `Resource` (adds HTTP via `requests`, Promise-based responses, URI template expansion, retry logic) -> Concrete resources (Customer, Invoice, Plan, etc.). + +Resources use Marshmallow for serialization. Each resource defines an inner `_Schema(Schema)` class with `@post_load` returning a class instance. Fields use `data_key` to map between Python snake_case and API kebab-case (e.g. `data_key="customer-since"`). + +Class-level attributes configure behavior: +- `_path`: URI template with `{/uuid}` syntax for optional path params +- `_root_key`: key name for list results (e.g. `"entries"`) +- `_many`: namedtuple for paginated list responses (entries + cursor + has_more) +- `_schema`: instantiated Schema with `unknown=EXCLUDE` +- `_bool_query_params`: list of query params to convert from Python bool to string + +HTTP methods are dynamically attached after class definition: +```python +Customer.all = Resource._method("all", "get") +Customer.create = Resource._method("create", "post") +``` + +Verb mapping: create->POST, all->GET, retrieve->GET, update->PUT, modify->PATCH, destroy->DELETE. + +All API calls return `Promise` objects. Use `.get()` for synchronous access. + +Nested objects that don't have their own API endpoint extend `DataObject` instead of `Resource`. + +## Testing + +Stack: unittest + requests_mock. Tests live in `test/api/test_.py`. + +Test pattern: +1. Define fixture dicts at module level matching API JSON responses +2. Use `@requests_mock.mock()` decorator +3. Register mock URI with expected headers, status, and JSON response +4. Call the SDK method with `Config("token")` and `.get()` for sync result +5. Assert call count, request body/params, and deserialized response types + +When adding a new resource, create a matching `test/api/test_.py` following this pattern. + +## Code style + +snake_case methods, PascalCase classes, UPPER_SNAKE_CASE constants. Leading underscore for internal attributes (`_path`, `_schema`). Linting via flake8 with max line length 100. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md