Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .claude/hooks/post-edit-lint.sh
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions .claude/hooks/post-stop-validate.sh
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions .claude/hooks/session-start-setup.sh
Original file line number Diff line number Diff line change
@@ -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
76 changes: 76 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
19 changes: 18 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -90,4 +90,21 @@ ENV/
# Rope project settings
.ropeproject

/.idea
/.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/
55 changes: 55 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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_<resource>.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_<resource>.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.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md