diff --git a/.claude/README.md b/.claude/README.md index 063a553..5b382c1 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -4,7 +4,7 @@ This directory contains Claude Code configuration and standards for this project ## Directory Structure -``` +```text .claude/ ├── README.md # This file ├── claude.md # Project-specific Claude guidelines @@ -94,6 +94,7 @@ git subtree push --prefix .claude/standard \ 3. **Project Overrides**: Finally loads `.claude/claude.md` (project-specific) This layered approach ensures: + - Consistent standards across all projects - Project flexibility where needed - Easy updates to universal guidelines diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index 16f518e..6e0f17b 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -9,24 +9,28 @@ Review code changes for quality, maintainability, and adherence to project stand ## Capabilities ### Code Analysis + - Identify code smells and anti-patterns - Check adherence to Python best practices - Evaluate code complexity and maintainability - Detect potential bugs and edge cases ### Standards Compliance + - Verify PEP 8 and project style guide compliance - Check type annotation completeness - Validate docstring coverage and quality - Ensure consistent naming conventions ### Security Review + - Identify potential security vulnerabilities - Check for hardcoded secrets or credentials - Validate input handling and sanitization - Review authentication and authorization logic ### Performance Review + - Identify potential performance bottlenecks - Check for unnecessary database queries (N+1) - Review memory usage patterns @@ -35,24 +39,28 @@ Review code changes for quality, maintainability, and adherence to project stand ## Review Checklist ### Code Quality + - [ ] Code is readable and self-documenting - [ ] Functions are single-purpose (SRP) - [ ] No unnecessary complexity - [ ] Error handling is appropriate ### Testing + - [ ] Tests cover new functionality - [ ] Edge cases are tested - [ ] Test names are descriptive - [ ] Mocks are used appropriately ### Documentation + - [ ] Public APIs are documented - [ ] Complex logic has comments - [ ] README updated if needed - [ ] CHANGELOG entry added ### Security + - [ ] No hardcoded secrets - [ ] Input validation present - [ ] SQL injection prevented @@ -60,6 +68,6 @@ Review code changes for quality, maintainability, and adherence to project stand ## Invocation -``` +```text /review or via Task tool with subagent_type='code-reviewer' ``` diff --git a/.claude/agents/merge-standards.md b/.claude/agents/merge-standards.md index 494b760..e4a0d86 100644 --- a/.claude/agents/merge-standards.md +++ b/.claude/agents/merge-standards.md @@ -94,7 +94,7 @@ This agent helps merge updated baseline standards from `.standards/` into the pr ## Merge Process -``` +```text 1. Read both baseline and target files 2. Identify what changed in baseline (git diff .standards/) 3. For each change: @@ -106,7 +106,7 @@ This agent helps merge updated baseline standards from `.standards/` into the pr ## Example Usage -``` +```text User: "Merge the updated baseline standards" Agent: diff --git a/.claude/agents/security-auditor.md b/.claude/agents/security-auditor.md index d8c8305..0cfd063 100644 --- a/.claude/agents/security-auditor.md +++ b/.claude/agents/security-auditor.md @@ -9,24 +9,28 @@ Proactively identify and mitigate security vulnerabilities, ensure compliance wi ## Capabilities ### Vulnerability Detection + - Static code analysis for security issues - Dependency vulnerability scanning - Secret detection and prevention - Configuration security review ### Threat Assessment + - Identify attack vectors - Assess risk levels - Prioritize security fixes - Document security findings ### Compliance Validation + - OWASP Top 10 compliance - Security policy adherence - Secure coding standards - Audit trail verification ### Security Testing + - Injection attack testing - Authentication testing - Authorization testing @@ -35,6 +39,7 @@ Proactively identify and mitigate security vulnerabilities, ensure compliance wi ## Security Checklist ### Code Security + - [ ] No hardcoded credentials - [ ] Input validation on all user input - [ ] Output encoding for XSS prevention @@ -43,18 +48,21 @@ Proactively identify and mitigate security vulnerabilities, ensure compliance wi - [ ] Proper error handling (no info leakage) ### Dependency Security + - [ ] No known vulnerabilities in dependencies - [ ] Dependencies up to date - [ ] Minimal dependency footprint - [ ] Trusted sources only ### Configuration Security + - [ ] Secrets in environment variables - [ ] Secure default configurations - [ ] TLS/SSL properly configured - [ ] CORS properly restricted ### Authentication & Authorization + - [ ] Strong password policies - [ ] Secure session management - [ ] Role-based access control @@ -91,6 +99,6 @@ gitleaks detect --source . ## Invocation -``` +```text /security or via Task tool with subagent_type='security-auditor' ``` diff --git a/.claude/agents/test-engineer.md b/.claude/agents/test-engineer.md index 969cbbf..f76400e 100644 --- a/.claude/agents/test-engineer.md +++ b/.claude/agents/test-engineer.md @@ -9,24 +9,28 @@ Design and implement test strategies, generate test cases, and ensure code quali ## Capabilities ### Test Strategy + - Design test plans and strategies - Identify critical paths for testing - Balance unit, integration, and e2e tests - Define coverage targets and metrics ### Test Generation + - Generate unit tests for new code - Create integration test scenarios - Design edge case and boundary tests - Implement property-based tests ### Test Review + - Review existing test quality - Identify gaps in test coverage - Suggest test improvements - Validate test isolation ### Test Automation + - Configure CI/CD test pipelines - Set up parallel test execution - Implement test reporting @@ -35,12 +39,14 @@ Design and implement test strategies, generate test cases, and ensure code quali ## Testing Standards ### Coverage Requirements + - **Minimum Coverage**: 80% - **Branch Coverage**: Enabled - **Critical Paths**: 100% coverage ### Test Organization -``` + +```text tests/ ├── unit/ # Fast, isolated tests ├── integration/ # Service integration tests @@ -50,6 +56,7 @@ tests/ ``` ### Test Quality Criteria + - Tests are deterministic (no flaky tests) - Tests are isolated (no shared state) - Tests are fast (< 1s for unit tests) @@ -74,6 +81,6 @@ uv run mutmut run ## Invocation -``` +```text /test or via Task tool with subagent_type='test-engineer' ``` diff --git a/.claude/commands/merge-standards.md b/.claude/commands/merge-standards.md index 0a83194..3343524 100644 --- a/.claude/commands/merge-standards.md +++ b/.claude/commands/merge-standards.md @@ -5,6 +5,7 @@ After running `cruft update`, merge any changes from `.standards/` into project ## Task 1. Check for changes in baseline files: + ```bash git diff .standards/ ``` diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md index 4e29abb..8a7f81e 100644 --- a/.claude/commands/plan.md +++ b/.claude/commands/plan.md @@ -32,14 +32,16 @@ Using the `project-planning` skill, generate these four documents with expert re For EACH document, follow this generate-review-refine cycle: ### 1. Generate Document + - Read the corresponding template from `.claude/skills/project-planning/templates/` - Generate project-specific content based on the user's description - Write to the appropriate location in `docs/planning/` ### 2. Expert Review (Consensus) + After writing each document, request expert review: -``` +```text mcp__zen__consensus with gemini-3-pro-preview: Review this [Document Type] for Python Libs. @@ -60,12 +62,15 @@ DOCUMENT: ``` ### 3. Refine if Needed + - If NEEDS REVISION: Incorporate feedback and re-submit for review - If READY: Proceed to next document - Each document must be READY before generating the next ### 4. Final Validation + After all documents pass review: + - Run validation script - Ensure cross-references are valid - Summarize outcomes @@ -99,6 +104,7 @@ This project was created from the cookiecutter-python-template with: ## Fallback (No MCP Server) If `mcp__zen__consensus` is not available: + - Skip the expert review step - Generate all documents sequentially - Run validation script for basic checks diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md index 5541169..99df774 100644 --- a/.claude/commands/pr.md +++ b/.claude/commands/pr.md @@ -42,9 +42,9 @@ Analyze the current branch and prepare a PR description following the project te ``` -4. **Output the PR description** ready to copy-paste into GitHub. +1. **Output the PR description** ready to copy-paste into GitHub. -5. **Suggest a PR title** following conventional commits: +2. **Suggest a PR title** following conventional commits: - `feat:` for new features - `fix:` for bug fixes - `docs:` for documentation @@ -57,7 +57,8 @@ Analyze the current branch and prepare a PR description following the project te **Title**: `feat: add user authentication with OAuth2` **Description**: -``` + +```markdown ## Summary Add OAuth2 authentication flow supporting Google and GitHub providers. diff --git a/.claude/commands/quality.md b/.claude/commands/quality.md index cc232d2..ce5eeaf 100644 --- a/.claude/commands/quality.md +++ b/.claude/commands/quality.md @@ -4,11 +4,12 @@ Run code quality checks including formatting, linting, and type checking. ## Usage -``` +```text /quality [scope] ``` **Arguments:** + - `scope` (optional): `all`, `format`, `lint`, `types` (default: `all`) ## Workflow @@ -21,22 +22,26 @@ Run code quality checks including formatting, linting, and type checking. ## Commands Executed ### Format + ```bash uv run black --check . uv run ruff format --check . ``` ### Lint + ```bash uv run ruff check . ``` ### Types + ```bash uv run basedpyright src/ ``` ### All (Pre-commit) + ```bash uv run pre-commit run --all-files ``` @@ -50,6 +55,7 @@ uv run pre-commit run --all-files ## Fix Issues To automatically fix formatting and some lint issues: + ```bash uv run black . uv run ruff check --fix . diff --git a/.claude/commands/security.md b/.claude/commands/security.md index 1a67a08..c9f3f4d 100644 --- a/.claude/commands/security.md +++ b/.claude/commands/security.md @@ -4,11 +4,12 @@ Run security validation including environment checks, vulnerability scanning, an ## Usage -``` +```text /security [scope] ``` **Arguments:** + - `scope` (optional): `all`, `env`, `scan`, `deps` (default: `all`) ## Workflow @@ -21,6 +22,7 @@ Run security validation including environment checks, vulnerability scanning, an ## Commands Executed ### Environment Validation + ```bash # Check GPG key gpg --list-secret-keys @@ -33,6 +35,7 @@ git config --get user.signingkey ``` ### Code Scanning + ```bash # Bandit security scanner uv run bandit -r src/ -c pyproject.toml @@ -42,6 +45,7 @@ uv run semgrep scan --config auto src/ ``` ### Dependency Audit + ```bash # Check for vulnerable dependencies uv run pip-audit @@ -51,6 +55,7 @@ uv run safety check ``` ### Secrets Detection + ```bash # Gitleaks scan gitleaks detect --source . @@ -69,6 +74,7 @@ trufflehog filesystem . ## OWASP Compliance Focus on preventing: + - A01: Broken Access Control - A03: Injection - A07: Authentication Failures diff --git a/.claude/commands/testing.md b/.claude/commands/testing.md index 63c5b9e..8b4d58b 100644 --- a/.claude/commands/testing.md +++ b/.claude/commands/testing.md @@ -4,27 +4,31 @@ Run tests with coverage reporting and optional test generation. ## Usage -``` +```text /testing [action] [scope] ``` **Arguments:** + - `action` (optional): `run`, `generate`, `review`, `coverage` (default: `run`) - `scope` (optional): `all`, `unit`, `integration`, `e2e` (default: `all`) ## Workflow ### Run Tests + 1. Execute pytest with specified scope 2. Generate coverage report 3. Report results and coverage percentage ### Generate Tests + 1. Analyze target code 2. Generate test cases for untested functions 3. Create test file with fixtures ### Review Tests + 1. Analyze existing test quality 2. Identify coverage gaps 3. Suggest improvements @@ -32,31 +36,37 @@ Run tests with coverage reporting and optional test generation. ## Commands Executed ### Run All Tests + ```bash uv run pytest -v --tb=short ``` ### Run with Coverage + ```bash uv run pytest --cov=src/python_libs --cov-report=html --cov-report=term-missing ``` ### Run Unit Tests Only + ```bash uv run pytest tests/unit/ -v --tb=short ``` ### Run Integration Tests + ```bash uv run pytest tests/integration/ -v --tb=short -m "integration" ``` ### Run E2E Tests + ```bash uv run pytest tests/e2e/ -v --tb=short -m "e2e" ``` ### Mutation Testing + ```bash uv run mutmut run --paths-to-mutate=src/ ``` @@ -69,7 +79,7 @@ uv run mutmut run --paths-to-mutate=src/ ## Test Organization -``` +```text tests/ ├── unit/ # Fast, isolated tests ├── integration/ # Service integration tests diff --git a/.claude/context/python-standards.md b/.claude/context/python-standards.md index 226ff13..e4e4e85 100644 --- a/.claude/context/python-standards.md +++ b/.claude/context/python-standards.md @@ -5,22 +5,26 @@ Standards and patterns for Python development in this project. ## Code Style ### Formatting + - **Formatter**: Black (88 character line length) - **Import Sorting**: Ruff isort rules - **Docstrings**: Google style ### Linting + - **Linter**: Ruff with PyStrict-aligned rules - **Type Checker**: BasedPyright (strict mode) ## Type Annotations ### Required Annotations + - All public function parameters - All public function return types - All class attributes ### Type Checking Configuration + ```toml [tool.basedpyright] pythonVersion = "3.12" @@ -33,6 +37,7 @@ strictSetInference = true ## Error Handling ### Patterns + ```python # Use specific exceptions def process_data(data: dict[str, Any]) -> Result: @@ -47,6 +52,7 @@ def process_data(data: dict[str, Any]) -> Result: ``` ### Anti-Patterns + ```python # Avoid bare except try: @@ -64,6 +70,7 @@ except Exception: # Too broad ## Testing Standards ### Test Organization + ```text tests/ ├── unit/ # Fast, isolated tests (<1s each) @@ -73,11 +80,13 @@ tests/ ``` ### Coverage Requirements + - **Minimum**: 80% - **Branch Coverage**: Enabled - **Critical Paths**: 100% ### Test Naming + ```python def test_function_name_when_condition_then_expected_result(): """Descriptive test names following Given-When-Then pattern.""" @@ -87,6 +96,7 @@ def test_function_name_when_condition_then_expected_result(): ## Documentation ### Docstring Format (Google Style) + ```python def function(param1: str, param2: int) -> bool: """Short description of function. @@ -112,16 +122,19 @@ def function(param1: str, param2: int) -> bool: ## Security Considerations ### Input Validation + - Always validate user input at boundaries - Use type annotations for runtime validation - Sanitize before processing ### Secret Management + - Never hardcode secrets - Use environment variables - Encrypt sensitive data at rest ### Dependency Management + - Pin dependency versions - Regular security audits - Use trusted sources only @@ -129,16 +142,19 @@ def function(param1: str, param2: int) -> bool: ## Performance Guidelines ### Database Queries + - Avoid N+1 queries - Use eager loading where appropriate - Index frequently queried columns ### Memory Management + - Use generators for large datasets - Clean up resources (context managers) - Profile memory usage ### Async Operations + - Use asyncio for I/O-bound operations - Avoid blocking calls in async contexts - Use connection pooling diff --git a/.claude/context/testing-patterns.md b/.claude/context/testing-patterns.md index de70029..b9fee0d 100644 --- a/.claude/context/testing-patterns.md +++ b/.claude/context/testing-patterns.md @@ -5,6 +5,7 @@ Common testing patterns and best practices for this project. ## Test Structure ### AAA Pattern (Arrange-Act-Assert) + ```python def test_user_creation(): # Arrange @@ -21,6 +22,7 @@ def test_user_creation(): ## Fixtures ### Basic Fixture + ```python @pytest.fixture def sample_user(): @@ -31,6 +33,7 @@ def test_user_display(sample_user): ``` ### Factory Fixture + ```python @pytest.fixture def user_factory(): @@ -45,6 +48,7 @@ def test_custom_user(user_factory): ``` ### Async Fixture + ```python @pytest.fixture async def async_client(): @@ -55,6 +59,7 @@ async def async_client(): ## Mocking ### Mock External Services + ```python @pytest.fixture def mock_api(mocker): @@ -67,6 +72,7 @@ def test_with_mock(mock_api): ``` ### Mock Database + ```python @pytest.fixture def mock_db(mocker): @@ -102,6 +108,7 @@ def test_encode_decode_roundtrip(text): ## Test Categories ### Unit Tests + ```python @pytest.mark.unit def test_pure_function(): @@ -109,6 +116,7 @@ def test_pure_function(): ``` ### Integration Tests + ```python @pytest.mark.integration def test_database_integration(db_session): @@ -117,6 +125,7 @@ def test_database_integration(db_session): ``` ### Slow Tests + ```python @pytest.mark.slow def test_complex_operation(): diff --git a/.claude/skills/commit-prepare/SKILL.md b/.claude/skills/commit-prepare/SKILL.md index 7ec0b22..7929e93 100644 --- a/.claude/skills/commit-prepare/SKILL.md +++ b/.claude/skills/commit-prepare/SKILL.md @@ -10,6 +10,7 @@ Automatically prepare commit messages following conventional commits standard. ## Activation This skill activates on keywords: + - "commit", "prepare commit", "commit this" - "commit message", "write commit" - "stage and commit", "commit changes" @@ -38,6 +39,7 @@ git diff --stat ### 2. Analyze Changes Determine: + - **Type**: feat, fix, docs, refactor, test, chore, perf, ci - **Scope**: Which component/module is affected (optional) - **Breaking**: Are there breaking changes? @@ -48,7 +50,7 @@ Determine: Follow conventional commits format: -``` +```text (): @@ -79,6 +81,7 @@ Follow conventional commits format: ### 4. Handle Staging If there are unstaged changes, ask: + - "Stage all changes?" (`git add .`) - "Stage specific files?" (list them) - "Review changes first?" (show diff) @@ -111,6 +114,7 @@ EOF ### 6. Post-Commit After successful commit: + - Show `git log -1` to confirm - Suggest `git push` if ready - Mention if more changes remain unstaged @@ -122,17 +126,20 @@ After successful commit: **User**: "commit these changes" **Claude**: + ```bash git status git diff --cached ``` Looking at your staged changes: + - Modified `src/auth/oauth.py` - added token refresh logic - Modified `tests/test_auth.py` - added refresh tests **Suggested commit**: -``` + +```text feat(auth): add OAuth token refresh Implement automatic token refresh when access token expires. @@ -150,7 +157,8 @@ Shall I commit with this message? **Claude**: I see you've changed the API response format. This is a breaking change. **Suggested commit**: -``` + +```text feat(api)!: change response envelope format BREAKING CHANGE: API responses now use { data, meta, errors } @@ -168,11 +176,13 @@ This marks it as a breaking change with `!`. Proceed? **User**: "commit this" **Claude**: I see changes to multiple unrelated areas: + 1. Bug fix in `parser.py` 2. New feature in `export.py` 3. Updated docs Recommend splitting into separate commits: + 1. `fix(parser): handle empty input gracefully` 2. `feat(export): add CSV export option` 3. `docs: update export documentation` @@ -183,7 +193,7 @@ Want me to help stage and commit each separately? Always include Claude attribution for AI-assisted commits: -``` +```text 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude diff --git a/.claude/skills/git/SKILL.md b/.claude/skills/git/SKILL.md index 906a46e..d18efdd 100644 --- a/.claude/skills/git/SKILL.md +++ b/.claude/skills/git/SKILL.md @@ -9,13 +9,16 @@ Auto-activates on keywords: git, branch, commit, pull request, PR, merge, rebase ## Workflows ### Branch Management + - **branch.md**: Branch creation, validation, and naming conventions - **status.md**: Repository status and health checks ### Commit Management + - **commit.md**: Conventional commit message preparation ### PR Workflow + - **pr-prepare.md**: Pull request description generation - **pr-check.md**: PR validation and checklist diff --git a/.claude/skills/pr-prepare/SKILL.md b/.claude/skills/pr-prepare/SKILL.md index 0b449a2..5ca058a 100644 --- a/.claude/skills/pr-prepare/SKILL.md +++ b/.claude/skills/pr-prepare/SKILL.md @@ -10,6 +10,7 @@ Automatically prepare pull request descriptions following project standards. ## Activation This skill activates on keywords: + - "prepare PR", "prepare the PR", "prepare a PR" - "create PR", "create pull request" - "PR description", "pull request description" @@ -41,6 +42,7 @@ git diff $(git merge-base HEAD main)..HEAD ### 2. Analyze Changes Identify: + - **Components modified**: Which files/modules changed - **Purpose**: Why these changes were made - **Impact**: Benefits, risks, breaking changes @@ -95,6 +97,7 @@ Follow conventional commits format: Present the complete PR description ready to copy-paste into GitHub. Remind the user: + - CodeRabbit will auto-fill `@coderabbitai summary` placeholder - They can push and create PR with `gh pr create` @@ -103,6 +106,7 @@ Remind the user: **User**: "Can you prepare the PR for this branch?" **Claude**: + 1. Runs git commands to gather context 2. Analyzes the changes 3. Outputs: @@ -143,6 +147,7 @@ Follow-up: Add Microsoft provider support --- Ready to copy! Push with: + ```bash git push -u origin HEAD gh pr create --fill diff --git a/.claude/skills/project-planning/SKILL.md b/.claude/skills/project-planning/SKILL.md index 123aaa2..67c0b6a 100644 --- a/.claude/skills/project-planning/SKILL.md +++ b/.claude/skills/project-planning/SKILL.md @@ -33,6 +33,7 @@ maintain context coherence across coding sessions and prevent architectural drif ### Step 1: Gather Project Context Before generating, collect: + 1. **Project description** from user input 2. **Technical constraints** from `pyproject.toml` and existing code 3. **Cookiecutter choices** reflected in project structure @@ -50,7 +51,7 @@ Generate documents sequentially, as later documents reference earlier ones: After generating each document, use the zen-mcp-server consensus tool to get expert review: -``` +```text Use mcp__zen__consensus with gemini-3-pro-preview to review: "Review this [document type] for sufficiency to begin development. @@ -71,6 +72,7 @@ Document content: ``` **Review each document in order**: + 1. PVS → Must be READY before generating ADR 2. ADR → Must be READY before generating Tech Spec 3. Tech Spec → Must be READY before generating Roadmap @@ -81,6 +83,7 @@ If any document NEEDS REVISION, incorporate feedback and re-review before procee ### Step 4: Validate and Cross-Reference After all documents pass review: + - Ensure documents reference each other correctly - Verify technical choices are consistent across documents - Flag any assumptions needing user validation @@ -101,6 +104,7 @@ After all documents pass review: Use template: `templates/pvs-template.md` Focus on: + - Problem being solved and user impact - Core capabilities (3-5 max for MVP) - Explicit scope boundaries (in vs out) @@ -111,6 +115,7 @@ Focus on: Use template: `templates/adr-template.md` Create ADR for: + - Database/storage choice - Authentication strategy - API design approach @@ -124,6 +129,7 @@ Format: `adr-001-{decision-slug}.md` Use template: `templates/tech-spec-template.md` Include: + - Complete tech stack with versions - Component architecture diagram (ASCII) - Data model with schemas @@ -136,12 +142,14 @@ Include: Use template: `templates/roadmap-template.md` Structure as: + - Phase 0: Foundation (environment, CI/CD) - Phase 1: MVP Core (essential features) - Phase 2: Enhancement (additional features) - Phase 3: Polish (testing, documentation) Each phase needs: + - Clear deliverables - Success criteria (testable) - Estimated duration @@ -174,6 +182,7 @@ Before completing generation: ## Templates Reference Templates are in `templates/` directory: + - `pvs-template.md` - Project Vision & Scope structure - `adr-template.md` - Architecture Decision Record structure - `tech-spec-template.md` - Technical Spec structure @@ -182,12 +191,14 @@ Templates are in `templates/` directory: ## Detailed Guidance For comprehensive documentation on each document type, see `reference/` directory: + - `reference/document-guide.md` - Full guidance for all document types - `reference/prompting-patterns.md` - How to use documents during development ## After Generation Instruct user to: + 1. Review each document for accuracy 2. Validate assumptions marked with `[ ]` 3. Adjust timelines in roadmap if needed @@ -227,7 +238,7 @@ When user says: "I want to build a CLI tool for managing personal finances..." ### Consensus Review Prompt Template -``` +```text mcp__zen__consensus with gemini-3-pro-preview: Review this Project Vision & Scope document for Python Libs. diff --git a/.claude/skills/project-planning/reference/document-guide.md b/.claude/skills/project-planning/reference/document-guide.md index fac276f..f9181f2 100644 --- a/.claude/skills/project-planning/reference/document-guide.md +++ b/.claude/skills/project-planning/reference/document-guide.md @@ -17,6 +17,7 @@ Comprehensive guidance for creating and maintaining project planning documents. ### The Problem They Solve AI-assisted development fails when: + - Massive context dumps overwhelm the model - Planning is skipped, leading to architectural drift - Decisions aren't documented, causing hallucinations @@ -24,7 +25,7 @@ AI-assisted development fails when: ### How They Work Together -``` +```text ┌─────────────────────────────┐ │ Project Vision & Scope │ ← WHAT & WHY │ (Problem, Users, Scope) │ @@ -56,11 +57,13 @@ AI-assisted development fails when: ### Project Vision & Scope (PVS) **Feeds into**: + - ADRs: Technical choices stem from requirements - Tech Spec: Features define what to implement - Roadmap: Scope determines work items **Updated when**: + - Major scope changes - New success metrics identified - Quarterly review @@ -68,14 +71,17 @@ AI-assisted development fails when: ### Architecture Decision Records (ADRs) **Feeds into**: + - Tech Spec: Decisions define implementation approach - Roadmap: Some decisions require phased migration **Referenced by**: + - Code comments where decisions are implemented - Tech Spec for rationale **Updated when**: + - New architectural decision made - Previous decision superseded - Post-implementation review reveals issues @@ -83,14 +89,17 @@ AI-assisted development fails when: ### Technical Implementation Spec **Feeds into**: + - Roadmap: Components map to work items - Code: Direct reference during implementation **References**: + - PVS for requirements context - ADRs for decision rationale **Updated when**: + - Architecture changes - Version upgrades - Security requirements change @@ -98,11 +107,13 @@ AI-assisted development fails when: ### Development Roadmap **References**: + - PVS for scope - Tech Spec for component breakdown - ADRs for dependencies **Updated when**: + - After each phase/sprint - Timeline shifts - New features added @@ -154,6 +165,7 @@ As mentioned elsewhere... **Solution**: Replace every placeholder with project-specific content **Bad**: + ```markdown ### Success Metrics - Improve user satisfaction @@ -161,6 +173,7 @@ As mentioned elsewhere... ``` **Good**: + ```markdown ### Success Metrics - Transaction import time: 30s → 5s (6x improvement) @@ -216,7 +229,7 @@ As mentioned elsewhere... ### Starting a New Session -``` +```text Load context from: - PVS sections 2-3 (core features) - ADR-001, ADR-002 (key decisions) @@ -227,7 +240,7 @@ Then implement [specific feature]. ### Validating AI-Generated Code -``` +```text Review this code against: - Tech Spec section 5 (security requirements) - ADR-003 (authentication decision) @@ -248,7 +261,7 @@ Flag any violations. ### Recommended Structure -``` +```text docs/ └── planning/ ├── README.md # Quick start guide diff --git a/.claude/skills/project-planning/reference/prompting-patterns.md b/.claude/skills/project-planning/reference/prompting-patterns.md index 6d5d381..88cf7d4 100644 --- a/.claude/skills/project-planning/reference/prompting-patterns.md +++ b/.claude/skills/project-planning/reference/prompting-patterns.md @@ -17,29 +17,29 @@ How to effectively use planning documents during AI-assisted development. Load only relevant sections, not entire documents: -``` +```text Load context from: - project-vision.md sections 2-3 (solution overview, scope) - adr/adr-001-database-choice.md (we're working on data layer) - tech-spec.md section 3 (data model) Then implement the User entity as defined. -``` +```text ### Feature-Specific Loading -``` +```text For implementing authentication: - Load adr/adr-002-auth-strategy.md - Load tech-spec.md sections 4, 6 (API spec, security) - Reference roadmap.md Phase 1 user stories Implement login endpoint. -``` +```text ### Resuming After Break -``` +```text Resuming development after [X] days. Current state from roadmap.md: @@ -48,7 +48,7 @@ Current state from roadmap.md: - M2 (CRUD operations) next Continue with US-003 from roadmap. -``` +```text --- @@ -57,29 +57,31 @@ Continue with US-003 from roadmap. ### Feature Implementation **Bad** (too broad): -``` + +```text Build the entire authentication system. -``` +```text **Good** (incremental): -``` + +```text Implement user registration endpoint per tech-spec.md section 4.1. Focus on input validation first, per ADR-002 validation strategy. -``` +```text ### Test-Driven Approach -``` +```text Write failing tests for login feature as defined in: - roadmap.md Phase 1, US-002 acceptance criteria - tech-spec.md section 4 (API specification) Then implement to make tests pass. -``` +```text ### Component by Component -``` +```text From tech-spec.md section 2 (Architecture): Implement [Component A] first: @@ -88,7 +90,7 @@ Implement [Component A] first: - Tests: [from roadmap acceptance criteria] Do not implement [Component B] yet (dependency). -``` +```text --- @@ -96,18 +98,18 @@ Do not implement [Component B] yet (dependency). ### Code Review Against Specs -``` +```text Review this authentication code against: - ADR-002 (JWT implementation decision) - tech-spec.md section 6 (security requirements) - tech-spec.md section 7 (error handling) Flag any violations or improvements. -``` +```text ### Architecture Compliance -``` +```text This PR adds a caching layer. Check against: @@ -116,11 +118,11 @@ Check against: - project-vision.md: Is caching in scope for MVP? Should we create ADR-00X for this decision? -``` +```text ### Security Validation -``` +```text Validate this code against tech-spec.md section 6 (Security): Check: @@ -130,7 +132,7 @@ Check: - [ ] No sensitive data in logs Report any violations. -``` +```text --- @@ -138,7 +140,7 @@ Report any violations. ### After Completing a Task -``` +```text Completed US-001 from roadmap.md Phase 1. Update roadmap.md: @@ -147,11 +149,11 @@ Update roadmap.md: - Note any discovered blockers Then proceed to US-002. -``` +```text ### After Making a Decision -``` +```text Decided to use Redis for caching instead of in-memory. Create docs/planning/adr/adr-004-caching-strategy.md: @@ -161,11 +163,11 @@ Create docs/planning/adr/adr-004-caching-strategy.md: - Consequences: New dependency, deployment change Update tech-spec.md section 1 (Technology Stack) to include Redis. -``` +```text ### After Scope Change -``` +```text Stakeholder requested: Add export to CSV feature. Update documents: @@ -174,7 +176,7 @@ Update documents: 3. roadmap.md: Add user story to appropriate phase Flag if this affects timeline. -``` +```text --- @@ -183,54 +185,62 @@ Flag if this affects timeline. ### Context Dumping **Bad**: -``` + +```text Here's my entire project-vision.md, tech-spec.md, and all ADRs. Now implement feature X. -``` +```text **Good**: -``` + +```text From tech-spec.md section 4.2 and ADR-001, implement the database migration for User entity. -``` +```text ### Vague References **Bad**: -``` + +```text Follow the spec. Per the architecture decision. -``` +```text **Good**: -``` + +```text Per tech-spec.md section 3.2 (User entity schema). Per ADR-001 decision to use PostgreSQL with UUID primary keys. -``` +```text ### Skipping Validation **Bad**: -``` + +```text Looks good, merge it. -``` +```text **Good**: -``` + +```text Before merging, validate against: - tech-spec.md section 6 (security) - roadmap.md Definition of Done checklist -``` +```text ### Ignoring Document Updates **Bad**: -``` + +```text We changed the approach but the docs still say the old way. -``` +```text **Good**: -``` + +```text Implementation differs from tech-spec.md section 3. Either: @@ -238,7 +248,7 @@ Either: 2. Refactor implementation to match spec Create ADR if this is a significant architectural change. -``` +```text --- @@ -247,32 +257,36 @@ Create ADR if this is a significant architectural change. ### Prompt Templates **Implement Feature**: -``` + +```text Per [doc] section [X], implement [feature]. Reference [ADR-XXX] for [specific decision]. Success criteria from roadmap.md: [criteria]. -``` +```text **Validate Code**: -``` + +```text Review against: - [doc] section [X] ([topic]) - [ADR-XXX] ([decision]) Flag violations. -``` +```text **Update Documents**: -``` + +```text Completed [task/decision]. Update: - [doc]: [what to change] - [doc]: [what to change] -``` +```text **Start Session**: -``` + +```text Load from: - [doc] sections [X-Y] - [ADR-XXX] Continue with [task]. -``` +```text diff --git a/.claude/skills/project-planning/scripts/validate-planning-docs.py b/.claude/skills/project-planning/scripts/validate-planning-docs.py old mode 100644 new mode 100755 diff --git a/.claude/skills/project-planning/templates/adr-template.md b/.claude/skills/project-planning/templates/adr-template.md index 985d2d6..a284017 100644 --- a/.claude/skills/project-planning/templates/adr-template.md +++ b/.claude/skills/project-planning/templates/adr-template.md @@ -100,6 +100,7 @@ ## Common Initial ADRs For new projects, typically create: + 1. `adr-001-database-choice.md` - Storage strategy 2. `adr-002-auth-strategy.md` - Authentication approach (if applicable) 3. `adr-003-api-design.md` - API patterns (if applicable) diff --git a/.claude/skills/project-planning/templates/roadmap-template.md b/.claude/skills/project-planning/templates/roadmap-template.md index 34f64b1..a25fd5f 100644 --- a/.claude/skills/project-planning/templates/roadmap-template.md +++ b/.claude/skills/project-planning/templates/roadmap-template.md @@ -12,12 +12,14 @@ ## Timeline Overview -``` +```text + Phase 0: Foundation ████████░░░░░░░░ (1 week) - Setup Phase 1: MVP Core ░░░░░░░░████████ (3 weeks) - Core features Phase 2: Enhancement ░░░░░░░░░░░░░░░░ (2 weeks) - Additional features Phase 3: Polish ░░░░░░░░░░░░░░░░ (1 week) - Testing & docs -``` + +```text ## Milestones @@ -162,7 +164,7 @@ A feature is complete when: - [Project Vision](./project-vision.md) - [Technical Spec](./tech-spec.md) - [Architecture Decisions](./adr/) -``` +```text ## Generation Notes diff --git a/.claude/skills/project-planning/templates/tech-spec-template.md b/.claude/skills/project-planning/templates/tech-spec-template.md index 91144ab..cfa85ba 100644 --- a/.claude/skills/project-planning/templates/tech-spec-template.md +++ b/.claude/skills/project-planning/templates/tech-spec-template.md @@ -39,7 +39,8 @@ [Monolith | Microservices | Serverless | etc] - See [ADR-XXX] ### Component Diagram -``` +```text + ┌─────────────────────────────────────────┐ │ [Component] │ ├─────────────┬─────────────┬─────────────┤ @@ -50,7 +51,8 @@ ┌─────────────────────────────────────────┐ │ [Data Layer] │ └─────────────────────────────────────────┘ -``` + +```text ### Component Responsibilities | Component | Purpose | Key Functions | @@ -69,46 +71,52 @@ class [Entity]: [field]: [type] created_at: datetime updated_at: datetime -``` +```text ### Relationships + - [Entity A] → [Entity B]: [Relationship type] ## 4. API Specification (if applicable) ### Endpoints + | Method | Path | Purpose | Auth | |--------|------|---------|------| | GET | /api/v1/[resource] | [Purpose] | [Yes/No] | | POST | /api/v1/[resource] | [Purpose] | [Yes/No] | ### Request/Response Format + ```json { "[field]": "[type]", "[field]": "[type]" } -``` +```text ## 5. CLI Specification (if applicable) ### Commands + | Command | Purpose | Example | |---------|---------|---------| | `[cmd] [subcmd]` | [Purpose] | `[example]` | ### Arguments + - `--[arg]`: [Description] (default: [value]) ## 6. Security ### Authentication + [Method]: [Details] - See [ADR-XXX] ### Authorization -[RBAC/ABAC/etc]: [Details] ### Data Protection + - **At Rest**: [Encryption method] - **In Transit**: [TLS/etc] - **Sensitive Data**: [Handling approach] @@ -116,14 +124,17 @@ class [Entity]: ## 7. Error Handling ### Strategy + [Approach to errors - fail fast, graceful degradation, etc] ### Error Codes + | Code | Meaning | User Action | |------|---------|-------------| | [Code] | [Description] | [What user should do] | ### Logging + - **Format**: Structured JSON - **Levels**: DEBUG, INFO, WARNING, ERROR - **Sensitive**: [What NOT to log] @@ -139,19 +150,23 @@ class [Entity]: ## 9. Testing Strategy ### Coverage Target + - Minimum: [X]% - Critical paths: 100% ### Test Types + - **Unit**: [Focus areas] - **Integration**: [Key scenarios] - **E2E**: [User workflows] ## Related Documents + - [Project Vision](./project-vision.md) - [Architecture Decisions](./adr/) - [Development Roadmap](./roadmap.md) -``` + +```text ## Generation Notes diff --git a/.claude/skills/project-planning/workflows/synthesize.md b/.claude/skills/project-planning/workflows/synthesize.md index 4fb93a6..bd7b99f 100644 --- a/.claude/skills/project-planning/workflows/synthesize.md +++ b/.claude/skills/project-planning/workflows/synthesize.md @@ -16,7 +16,7 @@ Run this workflow AFTER generating all planning documents: # Verify documents exist (not placeholders) ls -la docs/planning/ # Should show: project-vision.md, tech-spec.md, roadmap.md, adr/ -``` +```text ## Arguments @@ -45,13 +45,14 @@ if [ "$adr_count" -eq 0 ]; then exit 1 fi echo "✅ Found $adr_count ADR(s)" -``` +```text ### 2. Extract Information Read and extract key information from each document: **project-vision.md**: + - TL;DR / Executive summary - Problem statement - Target users @@ -61,6 +62,7 @@ Read and extract key information from each document: - Constraints **tech-spec.md**: + - Technology stack - Architecture overview - Data model @@ -69,6 +71,7 @@ Read and extract key information from each document: - Dependencies **roadmap.md**: + - Phase names and durations - Deliverables per phase - Success criteria @@ -76,6 +79,7 @@ Read and extract key information from each document: - Risks **adr/*.md**: + - Decision titles - Status - Key rationale @@ -96,7 +100,8 @@ For each phase in the roadmap, determine the appropriate branch type: | Refactoring | `refactor/` | No release | **Branch Naming**: -``` + +```text {type}/phase-{number}-{short-slug} Examples: @@ -105,7 +110,7 @@ feat/phase-1-core-features feat/phase-2-advanced perf/phase-3-optimization docs/phase-4-documentation -``` +```text ### 4. Generate PROJECT-PLAN.md @@ -153,13 +158,14 @@ source_documents: **Start a phase**: ```bash /git/milestone start {branch-name} -``` +```text **Complete a phase**: + ```bash /git/milestone complete /git/pr-prepare --include_wtd=true -``` +```text ## Phased Development @@ -178,9 +184,10 @@ source_documents: {criteria from roadmap} **Start Phase**: + ```bash /git/milestone start {branch-name} -``` +```text --- @@ -228,11 +235,13 @@ source_documents: 1. Review this synthesized plan for accuracy 2. Start Phase 0: + ```bash /git/milestone start feat/phase-0-foundation ``` -3. Track progress with TodoWrite -4. Complete phases with PR workflow + +1. Track progress with TodoWrite +2. Complete phases with PR workflow ## Document References @@ -240,7 +249,8 @@ source_documents: - [Technical Specification](./tech-spec.md) - [Development Roadmap](./roadmap.md) - [Architecture Decisions](./adr/) -``` + +```text ### 5. Create Initial TODO List @@ -263,7 +273,7 @@ Generate TodoWrite items for Phase 0: - [ ] Tests passing - [ ] Pre-commit checks pass - [ ] Create PR via /git/pr-prepare -``` +```text ### 6. Optional: Start First Phase @@ -275,13 +285,13 @@ If `--start-phase` argument provided: # Or if on main already: git checkout -b feat/phase-0-foundation -``` +```text ## Output Summary After synthesis: -``` +```text ✅ Created docs/planning/PROJECT-PLAN.md 📊 Plan Summary: @@ -294,30 +304,33 @@ After synthesis: 🚀 Next: Review plan, then run: /git/milestone start feat/phase-0-foundation -``` +```text ## Error Handling ### Missing Documents -``` + +```text ❌ Missing required documents: - project-vision.md (placeholder) - tech-spec.md (placeholder) Run first: /plan -``` +```text ### Conflicting Information -``` + +```text ⚠️ Inconsistency detected: - Roadmap Phase 1 duration: 3 weeks - Tech Spec estimates: 5 weeks Please clarify timeline before synthesis. -``` +```text ### No ADRs Found -``` + +```text ⚠️ No Architecture Decision Records found. Consider creating ADR for: @@ -326,7 +339,7 @@ Consider creating ADR for: - Key framework decisions Use: /adr create -``` +```text ## Related Commands diff --git a/.claude/skills/quality/SKILL.md b/.claude/skills/quality/SKILL.md index d28aa50..1377057 100644 --- a/.claude/skills/quality/SKILL.md +++ b/.claude/skills/quality/SKILL.md @@ -9,13 +9,16 @@ Auto-activates on keywords: quality, lint, format, precommit, naming, black, ruf ## Workflows ### Formatting + - **format.md**: Code formatting with Black and Ruff ### Linting + - **lint.md**: Linting checks with Ruff - **naming.md**: Naming convention validation ### Pre-commit + - **precommit.md**: Pre-commit hook validation ## Commands @@ -39,11 +42,13 @@ uv run pre-commit run --all-files ## Quality Standards ### Python Standards + - **Line Length**: 88 characters (Black default) - **Type Checking**: BasedPyright strict mode - **Linting**: Ruff with PyStrict-aligned rules ### Rule Categories + - **BLE**: Blind except detection - **EM**: Error message best practices - **SLF**: Private member access violations @@ -52,4 +57,5 @@ uv run pre-commit run --all-files - **G**: Logging format strings ### Per-File Ignores + Tests and scripts have relaxed rules for pragmatic development. diff --git a/.claude/skills/security/SKILL.md b/.claude/skills/security/SKILL.md index 0631210..e299759 100644 --- a/.claude/skills/security/SKILL.md +++ b/.claude/skills/security/SKILL.md @@ -9,12 +9,15 @@ Auto-activates on keywords: security, vulnerability, audit, OWASP, encryption, G ## Workflows ### Environment Validation + - **validate-env.md**: GPG/SSH key validation ### Scanning + - **scan.md**: Security vulnerability scanning ### Encryption + - **encrypt.md**: Secret encryption and management ## Commands @@ -43,11 +46,13 @@ uv run semgrep scan --config auto src/ ## Security Checklist ### Pre-Commit + - [ ] No secrets in code (checked by gitleaks) - [ ] Dependencies scanned for vulnerabilities - [ ] Bandit security scan passes ### Pre-Release + - [ ] All known vulnerabilities addressed - [ ] Security advisory published (if applicable) - [ ] Dependencies updated to secure versions diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index a815b80..cbe6f96 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -9,12 +9,15 @@ Auto-activates on keywords: test, coverage, pytest, unittest, integration test, ## Workflows ### Test Generation + - **generate.md**: Generate test cases for code ### Test Review + - **review.md**: Review existing tests for quality ### Specialized Testing + - **e2e.md**: End-to-end testing patterns - **security.md**: Security testing patterns - **performance.md**: Performance testing patterns @@ -46,7 +49,7 @@ uv run mutmut run --paths-to-mutate=src/ # Run property-based tests uv run pytest --hypothesis-show-statistics -``` +```text ## Coverage Standards @@ -56,7 +59,7 @@ uv run pytest --hypothesis-show-statistics ## Test Organization -``` +```text tests/ ├── unit/ # Unit tests (fast, isolated) ├── integration/ # Integration tests (may use external services) @@ -64,11 +67,12 @@ tests/ ├── security/ # Security-focused tests ├── performance/ # Performance and load tests └── conftest.py # Shared fixtures -``` +```text ## Testing Patterns ### AAA Pattern (Arrange-Act-Assert) + ```python def test_example(): # Arrange @@ -79,9 +83,10 @@ def test_example(): # Assert assert result == expected_output -``` +```text ### Fixtures + ```python @pytest.fixture def sample_data(): @@ -89,4 +94,4 @@ def sample_data(): def test_with_fixture(sample_data): assert sample_data["key"] == "value" -``` +```text diff --git a/.cruft.json b/.cruft.json index 5598b70..8ab5bc0 100644 --- a/.cruft.json +++ b/.cruft.json @@ -1,5 +1,5 @@ { - "template": "/home/byron/dev/cookiecutter-python-template", + "template": "https://github.com/ByronWilliamsCPA/cookiecutter-python-template", "commit": "ed9f899a0397975176940534242f30c2ed4be152", "checkout": null, "context": { @@ -103,7 +103,7 @@ "repo_url": "https://github.com/ByronWilliamsCPA/python-libs", "docs_url": "https://python-libs.readthedocs.io", "pypi_package_name": "python-libs", - "_template": "/home/byron/dev/cookiecutter-python-template", + "_template": "https://github.com/ByronWilliamsCPA/cookiecutter-python-template", "_commit": "ed9f899a0397975176940534242f30c2ed4be152" } }, diff --git a/.env.example b/.env.example index 095f454..d1a413f 100644 --- a/.env.example +++ b/.env.example @@ -169,5 +169,3 @@ SONAR_PROJECT_KEY=ByronWilliamsCPA_python_libs # ============================================================================ # Redis Configuration (Caching & Background Jobs) # ============================================================================ - - diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 0a5153a..32a1970 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -4,7 +4,7 @@ This project uses **org-level reusable workflows** for consistency and maintaina ## Architecture -``` +```text ┌─────────────────────────────────────────┐ │ python_libs │ │ (This Repository) │ @@ -18,7 +18,7 @@ This project uses **org-level reusable workflows** for consistency and maintaina │ │ • release.yml │ │ │ │ • sbom.yml │ │ │ │ • docs.yml │ │ -│ │ • publish-pypi.yml │ │ +│ │ • publish-artifact-registry.yml │ │ │ └───────────────────────────────────┘ │ │ │ │ │ │ uses: │ @@ -40,17 +40,18 @@ This project uses **org-level reusable workflows** for consistency and maintaina │ │ • python-release.yml │ │ │ │ • python-sbom.yml │ │ │ │ • python-docs.yml │ │ -│ │ • python-publish-pypi.yml │ │ │ └───────────────────────────────────┘ │ └─────────────────────────────────────────┘ -``` +```text ## Workflow Descriptions ### CI Pipeline (`ci.yml`) + **Calls**: `ByronWilliamsCPA/.github/.github/workflows/python-ci.yml@main` Comprehensive CI with: + - Multi-version Python testing (3.12) - UV dependency management - Ruff linting and formatting @@ -63,9 +64,11 @@ Comprehensive CI with: --- ### Security Analysis (`security-analysis.yml`) + **Calls**: `ByronWilliamsCPA/.github/.github/workflows/python-security-analysis.yml@main` Comprehensive security scanning with: + - CodeQL advanced analysis - Bandit static security analysis - Safety dependency CVE scanning @@ -78,46 +81,65 @@ Comprehensive security scanning with: --- ### Documentation (`docs.yml`) + **Calls**: `ByronWilliamsCPA/.github/.github/workflows/python-docs.yml@main` Documentation build and deployment: + - MkDocs build with Material theme - Link validation - Deployment to GitHub Pages (on push to main) **Triggers**: Push/PR affecting docs, manual dispatch + --- -### Publish to PyPI (`publish-pypi.yml`) -**Calls**: `ByronWilliamsCPA/.github/.github/workflows/python-publish-pypi.yml@main` +### Publish to Artifact Registry (`publish-artifact-registry.yml`) + +**Standalone workflow** (not org-level) - publishes packages to private GCP Artifact Registry. Package publishing with: -- OIDC trusted publishing (no API tokens needed) -- Test PyPI validation -- SBOM generation -- Signed releases -**Triggers**: Release published, manual dispatch +- Per-package version tags (e.g., `cloudflare-auth-v1.0.0`) +- Infisical secrets management for GCP credentials +- Service account authentication to Artifact Registry +- Dry-run support for testing + +**Triggers**: Package version tags, manual dispatch + +**Supported packages**: + +- `cloudflare-auth-v*` +- `cloudflare-api-v*` +- `gcs-utilities-v*` +- `gemini-image-v*` --- ### Release (`release.yml`) -**Calls**: `ByronWilliamsCPA/.github/.github/workflows/python-release.yml@main` + +**Standalone workflow** - creates GitHub releases with semantic versioning. Release automation with: -- SLSA provenance generation -- Signed artifacts -- Comprehensive changelog -- Asset upload -**Triggers**: Version tags (v*.*.*), manual dispatch +- Pre-release testing +- Conventional commit parsing +- Automatic version bumping +- GitHub Release creation with changelog + +**Triggers**: Push to main, manual dispatch + +**Note**: This workflow creates GitHub releases but does NOT publish packages. +Use `publish-artifact-registry.yml` via version tags to publish packages. --- ### SBOM & Security Scan (`sbom.yml`) + **Calls**: `ByronWilliamsCPA/.github/.github/workflows/python-sbom.yml@main` Software Bill of Materials and vulnerability scanning: + - CycloneDX SBOM generation - Trivy vulnerability scanning - License compliance checking @@ -130,22 +152,27 @@ Software Bill of Materials and vulnerability scanning: ## Benefits of Org-Level Reusable Workflows ### ✅ **Consistency** + All projects use the same tested CI/CD pipeline configuration. ### ✅ **Maintainability** + - Update workflows once at org level - All projects inherit improvements automatically - No need to update hundreds of project workflows ### ✅ **Reduced Duplication** + - Caller workflows are ~50 lines vs ~300+ for embedded workflows - 85% reduction in workflow code per project ### ✅ **Version Control** + - Workflows versioned at `@main` (or pin to specific version/SHA) - Easy rollback if needed ### ✅ **Security** + - Centralized security updates - Consistent security practices across org @@ -156,6 +183,7 @@ All projects use the same tested CI/CD pipeline configuration. Caller workflows are configured via `with:` parameters. See individual workflow files for available options. Example customization: + ```yaml jobs: ci: @@ -164,7 +192,7 @@ jobs: python-versions: '["3.10", "3.11", "3.12"]' # Test multiple versions coverage-threshold: 85 # Higher threshold basedpyright-strict: true # Strict type checking -``` +```text --- @@ -181,29 +209,34 @@ act -j security # Test with specific event act push -j ci -``` +```text --- ## Troubleshooting ### Workflow Fails to Find Reusable Workflow + **Error**: `Workflow file not found` **Solution**: Ensure the org-level `.github` repository exists and workflows are at: -``` + +```text ByronWilliamsCPA/.github/.github/workflows/*.yml -``` +```text ### Permission Denied + **Error**: `Resource not accessible by integration` **Solution**: Check workflow permissions. Caller workflows inherit permissions from reusable workflows, but may need additional `permissions:` blocks. ### Secrets Not Available + **Error**: `Secret not found` **Solution**: Add secrets at repository or organization level: + - Repository Settings → Secrets and variables → Actions - Organization Settings → Secrets and variables → Actions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a24b0a2..19fece9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true @@ -117,7 +117,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true @@ -159,7 +159,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e9aade2..1b310c1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true diff --git a/.github/workflows/fips-compatibility.yml b/.github/workflows/fips-compatibility.yml index e64066b..da23fac 100644 --- a/.github/workflows/fips-compatibility.yml +++ b/.github/workflows/fips-compatibility.yml @@ -36,7 +36,7 @@ on: strict_mode: description: 'Treat warnings as errors' required: false - default: 'false' + default: false type: boolean # Cancel in-progress runs for same PR/branch @@ -58,7 +58,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true @@ -204,7 +204,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index f9d8dee..195c126 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -65,7 +65,7 @@ jobs: python-version: "3.12" - name: Install UV - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true cache-dependency-glob: "uv.lock" diff --git a/.github/workflows/publish-artifact-registry.yml b/.github/workflows/publish-artifact-registry.yml new file mode 100644 index 0000000..eb860db --- /dev/null +++ b/.github/workflows/publish-artifact-registry.yml @@ -0,0 +1,256 @@ +# Publish to GCP Artifact Registry +# Publishes individual packages to private Artifact Registry on version tags. +# +# Trigger: Push tags matching pattern: {package-name}-v{version} +# - cloudflare-auth-v1.0.0 +# - gcs-utilities-v1.0.0 +# - cloudflare-api-v1.0.0 +# - gemini-image-v1.0.0 +# +# IMPORTANT: Tags must be created from the main branch only. +# The workflow validates this before publishing. +# +# Secrets are stored in GitHub Secrets (Infisical integration planned for future). +name: Publish to Artifact Registry + +on: + push: + tags: + - 'cloudflare-auth-v*' + - 'cloudflare-api-v*' + - 'gcs-utilities-v*' + - 'gemini-image-v*' + workflow_dispatch: + inputs: + package: + description: 'Package to publish' + required: true + type: choice + options: + - cloudflare-auth + - cloudflare-api + - gcs-utilities + - gemini-image + version: + description: 'Version to publish (e.g., 1.0.0)' + required: true + type: string + dry-run: + description: 'Dry run (build only, no publish)' + required: false + type: boolean + default: false + +# Prevent concurrent publishes of the same package +concurrency: + group: "publish-${{ github.ref }}" + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +env: + UV_VERSION: "0.5.x" + PYTHON_VERSION: "3.12" + +jobs: + publish: + name: Build and Publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + + - name: Validate tag is on main branch + if: github.event_name == 'push' + env: + GIT_REF: ${{ github.ref }} + run: | + # Get the commit the tag points to + TAG_COMMIT=$(git rev-list -n 1 "$GIT_REF") + + # Check if this commit is on the main branch + if git branch -r --contains "$TAG_COMMIT" | grep -q "origin/main"; then + echo "✅ Tag is on main branch" + else + echo "❌ Error: Tags must be created from the main branch only!" + echo " Tag commit: $TAG_COMMIT" + echo " Branches containing this commit:" + git branch -r --contains "$TAG_COMMIT" + exit 1 + fi + + - name: Parse tag to determine package + id: parse + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_PACKAGE: ${{ inputs.package }} + INPUT_VERSION: ${{ inputs.version }} + run: | + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + # Validate input_package is one of the allowed values + case "$INPUT_PACKAGE" in + cloudflare-auth|cloudflare-api|gcs-utilities|gemini-image) + PACKAGE="$INPUT_PACKAGE" + ;; + *) + echo "❌ Invalid package: $INPUT_PACKAGE" + exit 1 + ;; + esac + # Validate version format (semver-like) + if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then + echo "❌ Invalid version format: $INPUT_VERSION" + exit 1 + fi + VERSION="$INPUT_VERSION" + else + # Extract from tag: cloudflare-auth-v1.0.0 -> cloudflare-auth, 1.0.0 + TAG="${GITHUB_REF#refs/tags/}" + # Handle package names with hyphens (e.g., cloudflare-auth-v1.0.0) + VERSION="${TAG##*-v}" + PACKAGE="${TAG%-v*}" + fi + + # Map tag name to directory and pyproject package name + case "$PACKAGE" in + cloudflare-auth) + PKG_DIR="packages/cloudflare-auth" + PKG_NAME="byronwilliamscpa-cloudflare-auth" + ;; + cloudflare-api) + PKG_DIR="packages/cloudflare-api" + PKG_NAME="byronwilliamscpa-cloudflare-api" + ;; + gcs-utilities) + PKG_DIR="packages/gcs-utilities" + PKG_NAME="byronwilliamscpa-gcs-utilities" + ;; + gemini-image) + PKG_DIR="packages/gemini-image" + PKG_NAME="byronwilliamscpa-gemini-image" + ;; + *) echo "Unknown package: $PACKAGE" && exit 1 ;; + esac + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "pkg_name=$PKG_NAME" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "pkg_dir=$PKG_DIR" >> "$GITHUB_OUTPUT" + + echo "📦 Package: $PACKAGE" + echo "📦 PyPI Name: $PKG_NAME" + echo "📌 Version: $VERSION" + echo "📁 Directory: $PKG_DIR" + + - name: Verify version matches pyproject.toml + run: | + PKG_VERSION=$(grep -Po '(?<=^version = ")[^"]*' ${{ steps.parse.outputs.pkg_dir }}/pyproject.toml) + if [[ "$PKG_VERSION" != "${{ steps.parse.outputs.version }}" ]]; then + echo "❌ Version mismatch!" + echo " Tag version: ${{ steps.parse.outputs.version }}" + echo " pyproject.toml version: $PKG_VERSION" + exit 1 + fi + echo "✅ Version verified: $PKG_VERSION" + + # TODO: Re-enable Infisical once Cloudflare Access authentication is configured + # - name: Fetch secrets from Infisical + # uses: Infisical/secrets-action@v1.0.15 + # with: + # client-id: ${{ secrets.INFISICAL_CLIENT_ID }} + # client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }} + # project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }} + # env-slug: prod + # domain: https://infisical.williamshome.family + # export-type: env + + - name: Authenticate to Google Cloud + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 + with: + credentials_json: ${{ secrets.GCP_SA_KEY_BASE64 }} + + - name: Set up Cloud SDK + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2 + + - name: Configure Artifact Registry authentication + env: + GCP_SA_KEY_BASE64: ${{ secrets.GCP_SA_KEY_BASE64 }} + run: | + # Decode the base64 credentials and configure keyring + echo "$GCP_SA_KEY_BASE64" | base64 -d > /tmp/gcp-key.json + gcloud auth activate-service-account --key-file=/tmp/gcp-key.json + + # Get the repository URL + AR_URL="https://us-central1-python.pkg.dev/assured-oss-457903/python-libs/" + echo "AR_URL=$AR_URL" >> "$GITHUB_ENV" + echo "📦 Registry URL: $AR_URL" + + - name: Install uv + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 + with: + version: ${{ env.UV_VERSION }} + enable-cache: true + + - name: Set up Python + run: uv python install ${{ env.PYTHON_VERSION }} + + - name: Build package + run: | + echo "🔨 Building package..." + uv build --package ${{ steps.parse.outputs.pkg_name }} + echo "📦 Built artifacts:" + ls -la dist/ + + - name: Publish to Artifact Registry + if: ${{ !inputs.dry-run }} + run: | + echo "🚀 Publishing to Artifact Registry..." + + # Get access token from gcloud for authentication + ACCESS_TOKEN=$(gcloud auth print-access-token) + + # Use uv publish with token authentication + uv publish \ + --publish-url "$AR_URL" \ + --username oauth2accesstoken \ + --password "$ACCESS_TOKEN" \ + dist/* + + echo "✅ Published ${{ steps.parse.outputs.package }} v${{ steps.parse.outputs.version }}" + + - name: Dry run summary + if: ${{ inputs.dry-run }} + run: | + echo "🔍 Dry run completed - package was built but not published" + echo "📦 Package: ${{ steps.parse.outputs.package }}" + echo "📌 Version: ${{ steps.parse.outputs.version }}" + echo "📁 Artifacts:" + ls -la dist/ + + - name: Cleanup credentials + if: always() + run: rm -f /tmp/gcp-key.json + + - name: Job summary + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## 📦 Package Published + + | Field | Value | + |-------|-------| + | **Package** | \`${{ steps.parse.outputs.package }}\` | + | **Version** | \`${{ steps.parse.outputs.version }}\` | + | **Registry** | GCP Artifact Registry | + | **Status** | ${{ inputs.dry-run && '🔍 Dry Run' || '✅ Published' }} | + + ### Installation + + \`\`\`bash + # Configure UV/pip for private registry first + pip install byronwilliamscpa-${{ steps.parse.outputs.package }}==${{ steps.parse.outputs.version }} + \`\`\` + EOF diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml deleted file mode 100644 index f8f36fe..0000000 --- a/.github/workflows/publish-pypi.yml +++ /dev/null @@ -1,28 +0,0 @@ -# Publish to PyPI - Calls Org-Level Reusable Workflow -# This is a lightweight caller workflow that invokes the organization's -# shared PyPI publishing workflow with OIDC trusted publishing. -name: Publish to PyPI - -on: - release: - types: [published] - workflow_dispatch: - inputs: - test-pypi: - description: 'Publish to Test PyPI instead of PyPI' - type: boolean - required: false - default: false - -jobs: - publish: - name: Publish Package - uses: ByronWilliamsCPA/.github/.github/workflows/python-publish-pypi.yml@main - with: - python-version: '3.12' - package-name: 'python-libs' - test-pypi: ${{ github.event.inputs.test-pypi || false }} - -permissions: - contents: read - id-token: write # Required for OIDC trusted publishing diff --git a/.github/workflows/python-compatibility.yml b/.github/workflows/python-compatibility.yml index e81829f..a1f5a79 100644 --- a/.github/workflows/python-compatibility.yml +++ b/.github/workflows/python-compatibility.yml @@ -53,7 +53,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true @@ -65,6 +65,7 @@ jobs: uv sync --all-extras - name: Run tests + shell: bash run: | uv run pytest tests/ -v --tb=short -x \ --ignore=tests/integration \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3baddd0..5952994 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,14 @@ --- # Release Workflow -# Handles automated releases with semantic versioning or -# manual tag-based releases. +# Handles automated releases with semantic versioning. # # Features: # - Pre-release testing -# - Semantic versioning (if enabled) or manual tag-based releases -# - PyPI publishing with OIDC trusted publishing -# - GitHub Release creation +# - Semantic versioning based on conventional commits +# - GitHub Release creation with changelog +# +# Note: Package publishing to Artifact Registry is handled separately +# by publish-artifact-registry.yml triggered by version tags. name: Semantic Release "on": @@ -37,8 +38,6 @@ permissions: contents: write issues: write pull-requests: write - id-token: write - attestations: write # Standalone release workflow jobs: @@ -52,7 +51,7 @@ jobs: fetch-depth: 0 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true @@ -77,7 +76,7 @@ jobs: token: "${{ secrets.GITHUB_TOKEN }}" - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true @@ -93,8 +92,25 @@ jobs: with: github_token: "${{ secrets.GITHUB_TOKEN }}" - - name: Publish to PyPI + - name: Release summary if: steps.release.outputs.released == 'true' - uses: pypa/gh-action-pypi-publish@release/v1 - with: - attestations: true + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## 🎉 Release Created + + **Version**: \`${{ steps.release.outputs.version }}\` + + ### Next Steps + + To publish individual packages to Artifact Registry, create version tags: + + \`\`\`bash + # Update version in package's pyproject.toml first, then: + git tag cloudflare-auth-v${{ steps.release.outputs.version }} + git tag gcs-utilities-v${{ steps.release.outputs.version }} + git push origin --tags + \`\`\` + + The \`publish-artifact-registry.yml\` workflow will automatically publish + packages when their version tags are pushed. + EOF diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 512891e..4e15ec6 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -52,4 +52,3 @@ jobs: name: scorecard-results path: results.sarif retention-days: 5 - diff --git a/.github/workflows/security-analysis.yml b/.github/workflows/security-analysis.yml index 0eaa594..83f15f1 100644 --- a/.github/workflows/security-analysis.yml +++ b/.github/workflows/security-analysis.yml @@ -38,7 +38,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Install uv - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true diff --git a/.github/workflows/slsa-provenance.yml b/.github/workflows/slsa-provenance.yml index 84081dc..9ab6c01 100644 --- a/.github/workflows/slsa-provenance.yml +++ b/.github/workflows/slsa-provenance.yml @@ -56,7 +56,7 @@ jobs: python-version: "3.12" - name: Install UV - uses: astral-sh/setup-uv@582b2d78a0f5913301dcc87c4e93301fdd2b6711 # v4.1.1 + uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 with: enable-cache: true diff --git a/.gitignore b/.gitignore index 9fd1ca2..e5565b2 100644 --- a/.gitignore +++ b/.gitignore @@ -285,3 +285,9 @@ tmp_cleanup/.tmp-* # SonarCloud .scannerwork/ .sonar/ + +# Qlty cache symlinks (keep qlty.toml and configs) +.qlty/logs +.qlty/out +.qlty/plugin_cachedir +.qlty/results diff --git a/.markdownlint.json b/.markdownlint.json index a2236d3..dde27ac 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,9 +1,19 @@ { "default": true, "MD013": false, + "MD024": false, + "MD029": false, "MD033": false, "MD041": false, + "MD051": false, + "MD055": false, + "MD060": false, "line-length": false, "no-inline-html": false, - "first-line-heading": false + "first-line-heading": false, + "headings": false, + "ol-prefix": false, + "table-column-style": false, + "table-pipe-style": false, + "link-fragments": false } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d177f7a..6d01bc4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -63,7 +63,8 @@ repos: - id: trufflehog name: TruffleHog Secret Scanner description: Detect secrets in your data before committing - entry: bash -c 'command -v trufflehog >/dev/null 2>&1 && trufflehog git file://. --since-commit HEAD --results=verified,unknown --fail || echo "TruffleHog not installed - skipping (install: brew install trufflehog)"' + entry: >- + bash -c 'command -v trufflehog >/dev/null 2>&1 && trufflehog git file://. --since-commit HEAD --results=verified,unknown --fail || echo "TruffleHog not installed - skipping"' language: system pass_filenames: false stages: [pre-commit] @@ -224,4 +225,3 @@ exclude: | models/| .*\.pyc )$ - diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml index 01f98ba..e65b32e 100644 --- a/.qlty/qlty.toml +++ b/.qlty/qlty.toml @@ -1,11 +1,15 @@ # Qlty Configuration for Python Libs # This configuration uses Qlty for unified code quality analysis. # Note: Most security tools run via pre-commit hooks instead. - +# +# For configuration reference: https://qlty.sh/d/qlty-toml config_version = "0" # File patterns to completely exclude from all analysis exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", "*.log", "**/__pycache__/**", "**/*.pyc", @@ -22,6 +26,9 @@ exclude_patterns = [ "**/data/**", "**/models/**", "**/.git/**", + "**/testdata/**", + "**/vendor/**", + "**/tests/fixtures/**", ] # Patterns to identify test files for enhanced analysis @@ -29,8 +36,17 @@ test_patterns = [ "**/tests/**", "**/test_*.py", "**/*_test.py", + "**/*.test.*", + "**/*.spec.*", ] +# Default source for plugin definitions +[[source]] +name = "default" +default = true + +# Note: File exclude and test patterns are configured at the top-level + # ============================================================================ # Python Linting and Formatting - Ruff # ============================================================================ @@ -101,36 +117,52 @@ boolean_logic.threshold = 6 # Allow larger Python files for models, views, etc. file_complexity.threshold = 750 +# ============================================================================ +# Plugins - Using default source +# ============================================================================ + +# Python linting with Ruff +[[plugin]] +name = "ruff" +drivers = ["lint"] + +# Python security with Bandit (comment mode - subprocess use is intentional for validation) +# Note: B101 (assert) warnings in tests are expected and run in comment mode +# Note: Bandit configured in pyproject.toml to skip tests directory +[[plugin]] +name = "bandit" +mode = "comment" + +# Shell script linting (comment mode - script styling preferences) +[[plugin]] +name = "shellcheck" +mode = "comment" + +# YAML linting (comment mode - don't block CI for formatting) +[[plugin]] +name = "yamllint" +mode = "comment" + +# Markdown linting (comment mode - don't block CI) +[[plugin]] +name = "markdownlint" +mode = "comment" + +# GitHub Actions linting (comment mode - empty string in options is intentional) +[[plugin]] +name = "actionlint" +mode = "comment" + +# Vulnerability scanning (comment mode - vulnerabilities reported but don't block) +[[plugin]] +name = "osv-scanner" +mode = "comment" + # ============================================================================ # Note on Security Scanning # ============================================================================ -# Security tools are run via pre-commit hooks instead of qlty plugins: +# Additional security tools run via pre-commit hooks: # - detect-secrets: Secrets detection -# - bandit: Python security linter (via pyproject.toml [tool.bandit]) # - safety: Dependency vulnerability scanning # -# This approach is more reliable as qlty plugin availability varies. # See .pre-commit-config.yaml for the full security tool configuration. - -# ============================================================================ -# Documentation and Maintenance -# ============================================================================ -# This configuration is maintained as part of the project template. -# -# To update plugins: -# qlty plugins update -# -# To see the full merged configuration: -# qlty config show -# -# To validate this configuration: -# qlty config validate -# -# ============================================================================ -# Duplication Detection -# ============================================================================ -# Duplicate code detection is handled by the smells feature (mode="comment" above). -# Note: CPD (Copy-Paste Detector) is not available as a qlty plugin. - -# For more information: -# https://docs.qlty.sh/qlty-toml diff --git a/.sonarlint/connectedMode.json b/.sonarlint/connectedMode.json new file mode 100644 index 0000000..3e99c80 --- /dev/null +++ b/.sonarlint/connectedMode.json @@ -0,0 +1,5 @@ +{ + "sonarCloudOrganization": "byronwilliamscpa", + "projectKey": "ByronWilliamsCPA_python-libs", + "region": "EU" +} diff --git a/.standards/CLAUDE.baseline.md b/.standards/CLAUDE.baseline.md index bafc58f..e409ead 100644 --- a/.standards/CLAUDE.baseline.md +++ b/.standards/CLAUDE.baseline.md @@ -126,11 +126,13 @@ Claude MUST adopt a security-first approach in all development: For deployment on FIPS-enabled systems (Ubuntu LTS with fips-updates, government systems, healthcare, finance): **Prohibited algorithms** (will fail in FIPS mode): + - MD5, MD4, SHA-1 (for security purposes) - DES, 3DES, RC2, RC4, Blowfish - Non-approved key exchange methods **Required patterns**: + ```python # ✗ WRONG - Will fail on FIPS systems import hashlib @@ -144,11 +146,13 @@ h = hashlib.sha256(data) ``` **Check FIPS compatibility**: + ```bash uv run python scripts/check_fips_compatibility.py --fix-hints ``` **Problematic packages** (need verification or replacement): + - `bcrypt` → Use `passlib` with PBKDF2 or `argon2-cffi` - `pycrypto` → Use `pycryptodome` with FIPS mode - Verify `cryptography` version >= 3.4.6 with OpenSSL FIPS provider diff --git a/.standards/README.baseline.md b/.standards/README.baseline.md index 4124c0c..d1a39a7 100644 --- a/.standards/README.baseline.md +++ b/.standards/README.baseline.md @@ -152,12 +152,14 @@ When `.standards/README.baseline.md` is updated by cruft: 3. **Preserve project content** - Keep your custom Overview, Features, etc. **What to merge:** + - Badge URLs and formats (may add new badges) - Tool installation instructions (versions may change) - Quality standards tables (rules may be added) - Workflow documentation (process improvements) **What NOT to merge:** + - Your project's Overview section - Your project's Features section - Project-specific configuration diff --git a/.standards/README.md b/.standards/README.md index 5e5d6fd..64efbd0 100644 --- a/.standards/README.md +++ b/.standards/README.md @@ -34,11 +34,11 @@ git diff .standards/ # Option 2: Use the merge command /merge-standards -``` +```text ## Workflow -``` +```text ┌─────────────────┐ cruft update ┌──────────────────┐ │ Template │ ──────────────────► │ .standards/ │ │ Repository │ │ (baselines) │ @@ -50,7 +50,7 @@ git diff .standards/ │ Root files │ │ (customized) │ └──────────────────┘ -``` +```text ## Important Notes diff --git a/.standards/template_feedback.baseline.md b/.standards/template_feedback.baseline.md index 9381cd1..6b695b8 100644 --- a/.standards/template_feedback.baseline.md +++ b/.standards/template_feedback.baseline.md @@ -146,12 +146,14 @@ When `.standards/template_feedback.baseline.md` is updated by cruft: 4. **Preserve your feedback** - NEVER overwrite actual feedback items **What to merge:** + - Format template updates - New categories or priority definitions - Updated submission instructions - Example improvements **What NOT to merge:** + - Your actual feedback items - Your project-specific notes - Any local workarounds documented diff --git a/.yamllint b/.yamllint index 355e14d..3f6023d 100644 --- a/.yamllint +++ b/.yamllint @@ -3,6 +3,10 @@ extends: default rules: + # Disable truthy rule - "on:" is standard GitHub Actions syntax + truthy: + allowed-values: ["true", "false", "on", "off", "yes", "no"] + line-length: max: 150 level: warning diff --git a/CHANGELOG.md b/CHANGELOG.md index 147ca26..a08d750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added + - Initial project setup and structure ## [0.1.0] - TBD ### Added + - Initial project structure with Poetry package management - Pydantic v2 JSON schema validation - Structured logging with structlog and rich console output @@ -23,12 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - License ### Documentation + - README with project overview and quick start - CONTRIBUTING guidelines with development workflow - References to ByronWilliamsCPA org-level Security Policy - References to ByronWilliamsCPA org-level Code of Conduct ### Infrastructure + - Poetry dependency management with lock file - pytest test framework with coverage reporting - GitHub issue tracking and templates @@ -37,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI/CD pipeline with multiple quality gates ### Security + - Bandit security linting - Safety dependency vulnerability scanning - Pre-commit hooks for security validation diff --git a/CLAUDE.md b/CLAUDE.md index b86b496..dce0a60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ This feedback will be shared with the template team to improve the cookiecutter **Name**: Python Libs **Description**: Shared Python libraries for ByronWilliamsCPA projects - JWT auth, GCS utilities, and more **Author**: Byron Williams -**Repository**: https://github.com/ByronWilliamsCPA/python-libs +**Repository**: **Created**: 2025-12-04 ### Technology Stack @@ -39,6 +39,7 @@ This feedback will be shared with the template team to improve the cookiecutter - **Testing**: pytest, coverage - **Security**: Bandit, Safety - **Documentation**: MkDocs Material + --- GHA: Return GCP_SA_KEY_BASE64 +deactivate Infisical + +== GCP Authentication == +GHA -> GCP: Authenticate with\nService Account Key +activate GCP +GCP --> GHA: Authentication token +deactivate GCP + +== Build & Publish == +GHA -> GHA: Parse tag to determine\npackage directory +GHA -> GHA: Verify version in\npyproject.toml matches tag +GHA -> GHA: Build package with UV\n(uv build) + +GHA -> AR: Publish package\n(uv publish) +activate AR +AR --> GHA: Publish success +deactivate AR + +== Summary == +GHA -> GitHub: Update job summary\nwith publish details +deactivate GHA +deactivate GitHub + +note right of AR + **Registry URL:** + us-central1-python.pkg.dev/ + assured-oss-457903/python-libs + + **Supported Tags:** + - cloudflare-auth-v* + - cloudflare-api-v* + - gcs-utilities-v* + - gemini-image-v* +end note + +note left of Infisical + **Secrets Stored:** + - GCP_SA_KEY_BASE64 + (Service account JSON, base64) + + **Domain:** + secrets.byronwilliamscpa.com +end note + +@enduml +``` + + + +See also: [docs/diagrams/publish-workflow.puml](docs/diagrams/publish-workflow.puml) + +### How to Publish a Package + +1. **Update version** in the package's `pyproject.toml` +2. **Commit and push** the version change +3. **Create and push a tag** matching the pattern: + + ```bash + # Format: {package-name}-v{version} + git tag cloudflare-auth-v1.0.0 + git tag cloudflare-api-v1.0.0 + git tag gcs-utilities-v1.0.0 + git tag gemini-image-v1.0.0 + + git push origin --tags + ``` + +4. **GitHub Actions** automatically: + - Fetches GCP credentials from Infisical + - Verifies version matches tag + - Builds and publishes to Artifact Registry + +### Registry Details + +| Setting | Value | +|---------|-------| +| Registry URL | `us-central1-python.pkg.dev/assured-oss-457903/python-libs` | +| Secrets Manager | Infisical (secrets.byronwilliamscpa.com) | +| Service Account | `assured-oss-accessor@assured-oss-457903.iam.gserviceaccount.com` | + ## Versioning This project uses [Semantic Versioning](https://semver.org/): @@ -569,7 +735,7 @@ MIT License - see [LICENSE](LICENSE) for details. - **Issues**: [GitHub Issues](https://github.com/ByronWilliamsCPA/python-libs/issues) - **Discussions**: [GitHub Discussions](https://github.com/ByronWilliamsCPA/python-libs/discussions) -- **Email**: byronawilliams@gmail.com +- **Email**: ## Acknowledgments diff --git a/REUSE.toml b/REUSE.toml index 4b5bc91..94dddab 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -12,6 +12,8 @@ SPDX-PackageDownloadLocation = "https://github.com/ByronWilliamsCPA/python-libs" [[annotations]] path = [ "src/**", + "packages/**/src/**/*.py", + "packages/**/tests/**/*.py", "tools/**", "tests/**", "validation/**", @@ -30,6 +32,8 @@ SPDX-FileCopyrightText = "2025 Byron Williams" path = [ "docs/**", "*.md", + "packages/**/*.md", + "scripts/**/*.md", "tmp_cleanup/**/*.md", # Temporary reference files "benchmarks/*.md", # Benchmark documentation (not reports) "!LICENSES/*.txt", # Exclude actual license text files @@ -43,6 +47,8 @@ path = [ "*.toml", "*.yml", "*.yaml", + "*.json", + "*.lock", ".github/**", ".gitignore", ".gitattributes", @@ -50,9 +56,12 @@ path = [ ".readthedocs.yaml", "mkdocs.yml", "poetry.lock", # Dependency lockfile for reproducible builds + "uv.lock", # UV lockfile for reproducible builds "requirements/**/*.txt", # Generated from poetry export "benchmarks/labelmaps/**/*.yaml", # Benchmark label mappings ".zenodo.json", # Zenodo metadata for DOI + "packages/**/pyproject.toml", # Package configuration + "packages/**/py.typed", # PEP 561 type marker files ] SPDX-License-Identifier = "CC0-1.0" SPDX-FileCopyrightText = "2025 Byron Williams" @@ -91,8 +100,10 @@ SPDX-FileCopyrightText = "2025 Byron Williams" [[annotations]] path = [ ".claude/settings.local.json", + ".claude/settings.local.json.example", ".claude/**/*.json", # Claude configuration files ".claude/**/*.md", # Claude documentation files + ".claude/**/*.py", # Claude skill scripts ".standards/**", # Baseline standards (auto-updated by cruft) ".clusterfuzzlite/**", ".sonarlint/**", # SonarLint IDE configuration @@ -100,6 +111,13 @@ path = [ ".coderabbit.yaml", # CodeRabbit AI review configuration ".vscode/**", # VS Code settings ".idea/**", # IntelliJ/PyCharm settings + ".cruft.json", # Cruft template tracking + ".env.example", # Environment example file + ".markdownlint.json", # Markdownlint configuration + ".mutmut_config", # Mutation testing configuration + ".prettierrc", # Prettier configuration + ".shellcheckrc", # ShellCheck configuration + ".yamllint", # YAML lint configuration "concept.txt", "image_reference_sets.txt", "overrides/**", @@ -114,6 +132,8 @@ path = [ "Makefile", # Build automation "justfile", # Just command runner "Taskfile.yml", # Task runner configuration + "packages/**/tests/__init__.py", # Test package markers + "packages/**/tests/conftest.py", # Pytest fixtures ] SPDX-License-Identifier = "CC0-1.0" SPDX-FileCopyrightText = "2025 Byron Williams" diff --git a/docs/ADRs/adr-template.md b/docs/ADRs/adr-template.md index e09afc5..e178986 100644 --- a/docs/ADRs/adr-template.md +++ b/docs/ADRs/adr-template.md @@ -138,10 +138,12 @@ What aspects are neither good nor bad? **Approach**: How this approach would work **Advantages**: + - Specific benefit 1 - Specific benefit 2 **Disadvantages**: + - Specific drawback 1 - Specific drawback 2 @@ -154,10 +156,12 @@ What aspects are neither good nor bad? **Approach**: How this approach would work **Advantages**: + - Specific benefit 1 - Specific benefit 2 **Disadvantages**: + - Specific drawback 1 - Specific drawback 2 @@ -170,10 +174,12 @@ What aspects are neither good nor bad? **Approach**: How this approach would work **Advantages**: + - Specific benefit 1 - Specific benefit 2 **Disadvantages**: + - Specific drawback 1 - Specific drawback 2 diff --git a/docs/OPENSSF_COMPLIANCE.md b/docs/OPENSSF_COMPLIANCE.md index a32ca3e..4daeac7 100644 --- a/docs/OPENSSF_COMPLIANCE.md +++ b/docs/OPENSSF_COMPLIANCE.md @@ -395,7 +395,7 @@ The template meets 44/46 passing-level criteria: ## Getting Help -**Security Questions**: byronawilliams@gmail.com +**Security Questions**: **Vulnerability Reports**: See [Security Policy](https://github.com/ByronWilliamsCPA/.github/blob/main/SECURITY.md) **General Issues**: [GitHub Issues](https://github.com/ByronWilliamsCPA/python-libs/issues) diff --git a/docs/PROJECT_SETUP.md b/docs/PROJECT_SETUP.md index 6abd5d7..3d42a24 100644 --- a/docs/PROJECT_SETUP.md +++ b/docs/PROJECT_SETUP.md @@ -43,7 +43,7 @@ Your project was generated with the following configuration: # Create a new repository on GitHub, then: git remote add origin https://github.com/ByronWilliamsCPA/python-libs.git git push -u origin main -``` +```text ### 3. Install Dependencies @@ -56,7 +56,7 @@ uv sync --all-extras # Install pre-commit hooks uv run pre-commit install -``` +```text ### 4. Verify Setup @@ -69,7 +69,7 @@ uv run ruff check . # Run type checking uv run basedpyright src/ -``` +```text ### 5. Generate Project Planning Documents @@ -78,7 +78,7 @@ Use Claude Code to generate comprehensive planning documents for your project: ```bash # Open Claude Code and describe your project, then run: /plan -``` +```text See [Project Planning with Claude Code](#project-planning-with-claude-code) below for the complete workflow. @@ -92,7 +92,7 @@ This template includes an integrated AI-assisted project planning workflow that The planning workflow generates 4 core documents, then synthesizes them into a comprehensive project plan: -``` +```text Project Description │ ▼ @@ -128,7 +128,7 @@ Project Description │ Step 3: Start Development │ │ /git/milestone start │ └───────────────────────────────────────┘ -``` +```text ### Step 1: Generate Planning Documents @@ -140,18 +140,18 @@ Describe your project and generate the 4 core documents: The main users are individuals tracking expenses. Core features: expense tracking, budget categories, monthly reports. Technical constraints: must work offline, SQLite for storage. -``` +```text **Or provide a more detailed description:** -``` +```text Generate planning documents for this project: I'm building a REST API for inventory management. Target users are small business owners. Core features include product CRUD, stock tracking, low-stock alerts, and reporting. Must integrate with existing PostgreSQL database and support OAuth2 authentication. -``` +```text **What gets created:** @@ -163,6 +163,7 @@ existing PostgreSQL database and support OAuth2 authentication. | Architecture Decision Records | `docs/planning/adr/adr-001-*.md` | Key technical decisions with rationale | **The project-planning skill will:** + 1. Analyze your project description 2. Generate each document using templates 3. Validate each document with AI consensus review @@ -176,9 +177,10 @@ After the 4 documents are generated, synthesize them into an actionable plan: ```bash # Request synthesis "Synthesize my planning documents into a project plan" -``` +```text **The project-plan-synthesizer agent will:** + 1. Read and validate all 4 source documents 2. Extract key information from each document 3. Lookup best practices via Context7 for your tech stack @@ -188,6 +190,7 @@ After the 4 documents are generated, synthesize them into an actionable plan: 7. Create initial TodoWrite checklist for Phase 0 **Output:** `docs/planning/PROJECT-PLAN.md` containing: + - Git branch strategy for each phase - Consolidated risk register - Cross-referenced architecture decisions @@ -219,16 +222,17 @@ cat docs/planning/PROJECT-PLAN.md # - Phase deliverables match your expectations # - Git branch types align with semantic release # - Success criteria are measurable -``` +```text **Start development with the milestone workflow:** ```bash # Start Phase 0 (creates branch, sets up tracking) /git/milestone start feat/phase-0-foundation -``` +```text **This will:** + - Create the feature branch from main - Set up git worktree if parallel development is needed - Show semantic release impact for this branch type @@ -249,29 +253,29 @@ When you complete a phase: # 4. Start next phase /git/milestone start feat/phase-1-core -``` +```text ### Using Planning Documents During Development **Load context for a task:** -``` +```text Load context from project-vision.md sections 2-3 and adr/adr-001-*.md, then implement [feature] per tech-spec.md section [X]. -``` +```text **Validate code against specs:** -``` +```text Review this code against tech-spec.md section 6 (security). Flag any violations. -``` +```text **Check phase progress:** -``` +```text Review PROJECT-PLAN.md Phase 1 deliverables and update status. -``` +```text ### Document Update Guidelines @@ -310,7 +314,7 @@ The OpenSSF Best Practices badge requires manual project registration: ```markdown [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/YOUR_PROJECT_ID/badge)](https://www.bestpractices.dev/en/projects/YOUR_PROJECT_ID) -``` +```text **Tip**: Many questions can be answered "Met" based on this template's default configuration (CI/CD, security scanning, documentation, etc.). @@ -343,7 +347,7 @@ cruft check # See what would change cruft diff -``` +```text ### Apply Template Updates @@ -354,7 +358,7 @@ cruft update # If there are conflicts, resolve them manually # Then mark as resolved: cruft update --skip -``` +```text ### Best Practices for Updates @@ -377,7 +381,7 @@ git add # Complete the update cruft update --skip -``` +```text --- @@ -451,7 +455,7 @@ After registration, add this badge to your README's "Quality & Security" section ```markdown [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/YOUR_ID/badge)](https://www.bestpractices.dev/en/projects/YOUR_ID) -``` +```text --- @@ -471,9 +475,10 @@ uv run python scripts/setup_github_protection.py # Or specify custom settings uv run python scripts/setup_github_protection.py --enforce-admins --require-code-owner-reviews -``` +```text The script configures: + - Required pull request reviews before merging - Required status checks to pass - Enforce rules for administrators @@ -493,12 +498,14 @@ The script configures: ### Required Status Checks Add these as required checks: + - `CI / Test` (from CI workflow) - `CI / Lint` (from CI workflow) - `codecov/patch` - `SonarCloud Code Analysis` ### Security Policy + Your `SECURITY.md` file is already configured. Update these sections: 1. **Supported Versions**: Update as you release new versions @@ -515,7 +522,7 @@ reuse lint # Add license headers to new files reuse addheader --license MIT --copyright "Byron Williams" -``` +```text --- @@ -526,13 +533,16 @@ reuse addheader --license MIT --copyright "Byron Williams" 1. **Update version** in `pyproject.toml` 2. **Update CHANGELOG.md** with release notes 3. **Create a tag**: + ```bash git tag -a v1.0.0 -m "Release v1.0.0" git push origin v1.0.0 ``` -4. **Create GitHub Release** from the tag + +1. **Create GitHub Release** from the tag ### Dependency Updates + Renovate is configured to automatically create PRs for dependency updates. Review and merge these regularly. ### Code Quality Standards @@ -546,7 +556,7 @@ This project enforces: ### Project Structure -``` +```text python_libs/ ├── src/python_libs/ # Main package │ ├── core/ # Core functionality @@ -556,7 +566,7 @@ python_libs/ ├── .github/workflows/ # CI/CD workflows ├── pyproject.toml # Project configuration └── README.md # Project readme -``` +```text --- diff --git a/docs/PYTHON_COMPATIBILITY.md b/docs/PYTHON_COMPATIBILITY.md index 23e5c9e..03c4fd1 100644 --- a/docs/PYTHON_COMPATIBILITY.md +++ b/docs/PYTHON_COMPATIBILITY.md @@ -189,6 +189,7 @@ python -c "import sys; print(f'GIL enabled: {sys._is_gil_enabled()}')" ``` **Important Considerations:** + - Not all packages support free-threaded mode yet - Some C extensions require GIL - Performance may vary - benchmark your workload @@ -209,6 +210,7 @@ def func(x: expensive_type_check()): # Deferred until introspection ``` **Impact on Runtime Type Checking:** + - Libraries like Pydantic and dataclasses handle this automatically - Custom type introspection code may need updates - Access annotations via `__annotations__` or `inspect.get_annotations()` @@ -227,12 +229,14 @@ This project does **not** require t-strings - standard f-strings work across all ### Deprecations **`from __future__ import annotations` is deprecated:** + - This template's `check_type_hints.py` currently enforces this import - Deprecation in 3.14, removal not before Python 3.13 EOL (2029) - Continue using it for now (required for 3.10 compatibility) - We'll update the template before 2029 **NotImplemented Boolean Context:** + - Using `NotImplemented` in boolean contexts now raises `TypeError` - Was `DeprecationWarning` since Python 3.9 diff --git a/docs/api-reference.md b/docs/api-reference.md index a28d45c..092ab49 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -26,4 +26,3 @@ Complete API documentation for Python Libs. options: show_root_heading: true members_order: source - diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 3e55ea6..395c190 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -13,7 +13,7 @@ This document describes the architecture and design decisions for Python Libs. ## Project Structure -``` +```text python_libs/ ├── src/ │ └── python_libs/ @@ -27,7 +27,7 @@ python_libs/ ├── tests/ # Test suite ├── docs/ # Documentation └── pyproject.toml # Project configuration -``` +```text ## Design Principles diff --git a/docs/development/code-quality.md b/docs/development/code-quality.md index 1e5dd81..979eca3 100644 --- a/docs/development/code-quality.md +++ b/docs/development/code-quality.md @@ -72,6 +72,7 @@ uv run pre-commit run --all-files ## Quality Gates All PRs must pass: + - [ ] All tests passing - [ ] 80%+ coverage - [ ] No type errors diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 116e6d4..cb1ed8a 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -77,6 +77,7 @@ Push your branch and create a PR against `main`. ## Code Review All contributions require: + - Passing CI checks - Code review approval - Documentation updates (if applicable) diff --git a/docs/development/testing.md b/docs/development/testing.md index 54508d2..2a488e5 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -21,23 +21,23 @@ uv run pytest -v --cov=src --cov-report=term-missing # Run with nox across Python versions nox -s test -``` +```text ### Unit Tests Only ```bash uv run pytest -m unit -v -``` +```text ### Integration Tests ```bash uv run pytest -m integration -v -``` +```text ## Test Structure -``` +```text tests/ ├── conftest.py # Shared fixtures ├── unit/ # Unit tests @@ -45,7 +45,7 @@ tests/ │ └── test_logging.py └── integration/ # Integration tests └── ... -``` +```text ## Coverage Requirements @@ -74,11 +74,12 @@ def test_integration_example(): def test_slow_example(): """Slow test - excluded from fast runs.""" pass -``` +```text ## Continuous Integration Tests run automatically on: + - Pull request creation - Push to main/develop branches - Scheduled nightly builds diff --git a/docs/diagrams/publish-workflow.puml b/docs/diagrams/publish-workflow.puml new file mode 100644 index 0000000..3d1478c --- /dev/null +++ b/docs/diagrams/publish-workflow.puml @@ -0,0 +1,70 @@ +@startuml publish-workflow +!theme plain +skinparam backgroundColor #FEFEFE +skinparam sequenceMessageAlign center + +title Package Publishing Workflow\nGCP Artifact Registry with Infisical Secrets + +actor Developer +participant "GitHub\nRepository" as GitHub +participant "GitHub\nActions" as GHA +participant "Infisical\nSecrets" as Infisical +participant "Google Cloud\nAuth" as GCP +participant "Artifact\nRegistry" as AR + +== Tag Creation == +Developer -> GitHub: Push version tag\n(e.g., cloudflare-auth-v1.0.0) +activate GitHub + +GitHub -> GHA: Trigger publish workflow +activate GHA + +== Secret Retrieval == +GHA -> Infisical: Authenticate with\nClient ID/Secret +activate Infisical +Infisical --> GHA: Return GCP_SA_KEY_BASE64 +deactivate Infisical + +== GCP Authentication == +GHA -> GCP: Authenticate with\nService Account Key +activate GCP +GCP --> GHA: Authentication token +deactivate GCP + +== Build & Publish == +GHA -> GHA: Parse tag to determine\npackage directory +GHA -> GHA: Verify version in\npyproject.toml matches tag +GHA -> GHA: Build package with UV\n(uv build) + +GHA -> AR: Publish package\n(uv publish) +activate AR +AR --> GHA: Publish success +deactivate AR + +== Summary == +GHA -> GitHub: Update job summary\nwith publish details +deactivate GHA +deactivate GitHub + +note right of AR + **Registry URL:** + us-central1-python.pkg.dev/ + assured-oss-457903/python-libs + + **Supported Tags:** + - cloudflare-auth-v* + - cloudflare-api-v* + - gcs-utilities-v* + - gemini-image-v* +end note + +note left of Infisical + **Secrets Stored:** + - GCP_SA_KEY_BASE64 + (Service account JSON, base64) + + **Domain:** + secrets.byronwilliamscpa.com +end note + +@enduml diff --git a/docs/index.md b/docs/index.md index 1190af4..604a699 100644 --- a/docs/index.md +++ b/docs/index.md @@ -27,6 +27,7 @@ uv sync --all-extras - Type-safe with BasedPyright strict mode - Comprehensive test coverage - Structured logging with structlog + ## Documentation - [User Guide](guides/overview.md) - Getting started and usage diff --git a/docs/planning/README.md b/docs/planning/README.md index 0f20ac0..cbd50ca 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -28,7 +28,7 @@ This directory contains the essential planning documents for Python Libs. # 4. Start development /git/milestone start feat/phase-0-foundation -``` +```text ## Documents @@ -43,28 +43,29 @@ This directory contains the essential planning documents for Python Libs. ### Starting a Session -``` +```text Load context from: - project-vision.md sections 2-3 - adr/adr-001-*.md - tech-spec.md section [relevant section] Then implement [feature]. -``` +```text ### Validating Code -``` +```text Review this code against: - tech-spec.md section 6 (security) - adr/adr-002-*.md (relevant decision) Flag any violations. -``` +```text ### Updating Documents Update documents when: + - **Roadmap**: After completing tasks - **ADR**: When making architectural decisions - **Tech Spec**: When architecture changes @@ -72,7 +73,7 @@ Update documents when: ## Document Relationships -``` +```text ┌─────────────────────────────┐ │ Project Vision & Scope │ ← WHAT & WHY └──────────────┬──────────────┘ @@ -91,7 +92,7 @@ Update documents when: ┌─────────────────────────────┐ │ Development Roadmap │ ← WHEN └─────────────────────────────┘ -``` +```text ## More Information diff --git a/docs/planning/project-plan-template.md b/docs/planning/project-plan-template.md index 23d314c..7446e6d 100644 --- a/docs/planning/project-plan-template.md +++ b/docs/planning/project-plan-template.md @@ -30,11 +30,13 @@ Python Libs is a [brief description of what the project does]. This document out **Key Innovation**: [What makes this approach unique or valuable?] **Expected Outcomes**: + - Objective 1 - Objective 2 - Objective 3 **Success Criteria**: + - Metric 1: [target value or range] - Metric 2: [target value or range] - Metric 3: [target value or range] @@ -46,12 +48,14 @@ Python Libs is a [brief description of what the project does]. This document out ### ✅ IN SCOPE - What This Project Does **Core Responsibilities**: + - Feature/Capability 1 - Feature/Capability 2 - Feature/Capability 3 - Feature/Capability 4 **Deliverables**: + - Deliverable 1 - Deliverable 2 - Deliverable 3 @@ -59,11 +63,13 @@ Python Libs is a [brief description of what the project does]. This document out ### ❌ OUT OF SCOPE - What This Project Does NOT Do **Explicitly Excluded**: + - Out of scope item 1 - Out of scope item 2 - Out of scope item 3 **Why Out of Scope**: + - Reason 1 - Reason 2 @@ -75,7 +81,7 @@ Python Libs is a [brief description of what the project does]. This document out [Describe the overall system design] -``` +```text ┌─────────────────────────────────────┐ │ Component/Module 1 │ ├─────────────────────────────────────┤ @@ -89,21 +95,24 @@ Python Libs is a [brief description of what the project does]. This document out │ • Responsibility 1 │ │ • Responsibility 2 │ └─────────────────────────────────────┘ -``` +```text ### Module Responsibilities **[Module 1 Name](relative/path/to/module.py)** + - Core functionality description - Key features and capabilities - Dependencies and integration points **[Module 2 Name](relative/path/to/module.py)** + - Core functionality description - Key features and capabilities - Dependencies and integration points **[Module 3 Name](relative/path/to/module.py)** + - Core functionality description - Key features and capabilities - Dependencies and integration points @@ -112,11 +121,11 @@ Python Libs is a [brief description of what the project does]. This document out [Describe how data flows through the system] -``` +```text Input → Processing → Output ↓ ↓ ↓ Step1 Step2 Step3 -``` +```text --- @@ -151,9 +160,10 @@ docs/phase-4-documentation # Documentation (no release) # Or manually for simple phases git checkout -b feat/phase-1-core -``` +```text **Phase Completion Workflow**: + 1. Complete all deliverables in phase branch 2. Run `/git/milestone complete` for validation 3. Create PR with `/git/pr-prepare --include_wtd=true` @@ -167,11 +177,13 @@ git checkout -b feat/phase-1-core **Branch**: `feat/phase-0-foundation` **Objectives**: + - Establish project structure and configuration - Setup development infrastructure - Create baseline documentation **Key Deliverables**: + - [ ] Project skeleton with Poetry configuration - [ ] Git repository with branch protection - [ ] Pre-commit hooks and CI/CD pipeline @@ -179,18 +191,22 @@ git checkout -b feat/phase-1-core - [ ] Development environment setup guide **Technical Details**: + - Language/Framework: [Technology stack] - Testing Framework: Pytest - Documentation: MkDocs or Sphinx - CI/CD: GitHub Actions **Dependencies**: + - None (foundation phase) **Risks & Mitigations**: + - Risk 1: [Risk description] → Mitigation: [How to prevent/handle] **Success Criteria**: + - [ ] All tests pass (80%+ coverage) - [ ] All pre-commit hooks pass - [ ] Documentation builds without errors @@ -203,11 +219,13 @@ git checkout -b feat/phase-1-core **Branch**: `feat/phase-1-core` **Objectives**: + - Implement core functionality - Create API/CLI interface - Build comprehensive test suite **Key Deliverables**: + - [ ] Core module implementation - [ ] CLI tool (if applicable) - [ ] Unit tests (>80% coverage) @@ -217,31 +235,37 @@ git checkout -b feat/phase-1-core **Technical Details**: **Feature 1: [Name]** + - Description: [What it does] - Input/Output: [Data contract] - Performance Target: [Latency, throughput, etc.] - Dependencies: [Required libraries/services] **Feature 2: [Name]** + - Description: [What it does] - Input/Output: [Data contract] - Performance Target: [Latency, throughput, etc.] - Dependencies: [Required libraries/services] **Dependencies**: + - Dependency 1 (v1.2.3) - Dependency 2 (v2.0.0) **Milestones**: + - Week 3: Feature 1 implementation and testing - Week 4: Feature 2 implementation and testing - Week 5: Integration testing and documentation **Risks & Mitigations**: + - Risk 1: [Risk] → Mitigation: [Solution] - Risk 2: [Risk] → Mitigation: [Solution] **Success Criteria**: + - [ ] All features implemented and tested - [ ] Code coverage ≥80% - [ ] All pre-commit checks pass @@ -255,11 +279,13 @@ git checkout -b feat/phase-1-core **Branch**: `feat/phase-2-advanced` **Objectives**: + - Add advanced/optional features - Performance optimization - Enhanced error handling **Key Deliverables**: + - [ ] Advanced feature implementation - [ ] Performance benchmarks - [ ] Error handling and logging @@ -268,24 +294,29 @@ git checkout -b feat/phase-1-core **Technical Details**: **Feature 3: [Name]** + - Description: [What it does] - Input/Output: [Data contract] - Performance Target: [Latency, throughput, etc.] **Feature 4: [Name]** + - Description: [What it does] - Input/Output: [Data contract] - Performance Target: [Latency, throughput, etc.] **Dependencies**: + - Dependency 3 (v1.0.0) [Optional for advanced features] **Milestones**: + - Week 6: Feature 3 implementation - Week 7: Feature 4 implementation and optimization - Week 8: Integration and documentation **Success Criteria**: + - [ ] All features implemented - [ ] Performance targets achieved - [ ] Backward compatibility maintained @@ -297,11 +328,13 @@ git checkout -b feat/phase-1-core **Branch**: `perf/phase-3-optimization` **Objectives**: + - Performance optimization - Code refactoring - Production hardening **Key Deliverables**: + - [ ] Performance benchmarks report - [ ] Refactored code with improved maintainability - [ ] Production deployment guide @@ -310,24 +343,29 @@ git checkout -b feat/phase-1-core **Technical Details**: **Optimization Areas**: + - Area 1: [What to optimize] → Target: [Performance metric] - Area 2: [What to optimize] → Target: [Performance metric] **Refactoring**: + - Module 1 refactoring: [Description] - Module 2 refactoring: [Description] **Performance Targets**: + - Latency: <100ms per operation (CPU) - Throughput: >100 ops/sec - Memory usage: <100MB per worker **Milestones**: + - Week 9: Performance profiling and optimization plan - Week 10: Implement optimizations - Week 11: Benchmarking and verification **Success Criteria**: + - [ ] Performance targets met - [ ] No functionality regression - [ ] Code quality maintained (>80% coverage) @@ -339,11 +377,13 @@ git checkout -b feat/phase-1-core **Branch**: `docs/phase-4-documentation` **Objectives**: + - Complete documentation - Prepare for production release - Create training materials **Key Deliverables**: + - [ ] Complete API documentation - [ ] User guides and tutorials - [ ] Deployment documentation @@ -354,22 +394,26 @@ git checkout -b feat/phase-1-core **Technical Details**: **Documentation**: + - API Reference: Auto-generated from docstrings - User Guide: Step-by-step usage instructions - Architecture: ADRs and design documents - Operations: Deployment, scaling, monitoring **Release Preparation**: + - Version bumping to 1.0.0 - Changelog compilation - Release notes with migration guide - GitHub release creation **Milestones**: + - Week 12: Documentation review and updates - Week 13: Release preparation and publication **Success Criteria**: + - [ ] All documentation complete and current - [ ] Version 1.0.0 released - [ ] Release notes published diff --git a/docs/planning/project-vision.md b/docs/planning/project-vision.md index 5011fa0..3660225 100644 --- a/docs/planning/project-vision.md +++ b/docs/planning/project-vision.md @@ -23,13 +23,13 @@ This document will be generated by Claude Code based on your project description Or manually: -``` +```text Generate the Project Vision & Scope document for this project. Use the template in .claude/skills/project-planning/templates/pvs-template.md Write output to docs/planning/project-vision.md Project concept: [describe your project here] -``` +```text ## What This Document Contains diff --git a/docs/planning/roadmap.md b/docs/planning/roadmap.md index 5c88198..4ce0f8e 100644 --- a/docs/planning/roadmap.md +++ b/docs/planning/roadmap.md @@ -23,13 +23,13 @@ This document will be generated by Claude Code based on your project description Or manually: -``` +```text Generate the Development Roadmap for this project. Use the template in .claude/skills/project-planning/templates/roadmap-template.md Write output to docs/planning/roadmap.md Project concept: [describe your project here] -``` +```text ## What This Document Contains diff --git a/docs/planning/tech-spec.md b/docs/planning/tech-spec.md index d449b30..24460d6 100644 --- a/docs/planning/tech-spec.md +++ b/docs/planning/tech-spec.md @@ -23,13 +23,13 @@ This document will be generated by Claude Code based on your project description Or manually: -``` +```text Generate the Technical Implementation Specification for this project. Use the template in .claude/skills/project-planning/templates/tech-spec-template.md Write output to docs/planning/tech-spec.md Project concept: [describe your project here] -``` +```text ## Pre-Filled Context diff --git a/docs/project/license.md b/docs/project/license.md index 4042c4b..3d4ff2b 100644 --- a/docs/project/license.md +++ b/docs/project/license.md @@ -23,6 +23,7 @@ The MIT License is a permissive license that allows: With the following conditions: - **License and copyright notice**: Include the license in distributions + ## Full License Text See the [LICENSE](https://github.com/ByronWilliamsCPA/python-libs/blob/main/LICENSE) file in the repository root. @@ -33,4 +34,4 @@ This project uses open-source dependencies. See `pyproject.toml` for the full li ## Contact -For licensing questions, contact: byronawilliams@gmail.com +For licensing questions, contact: diff --git a/docs/template_feedback.md b/docs/template_feedback.md index 9bc369a..74761d7 100644 --- a/docs/template_feedback.md +++ b/docs/template_feedback.md @@ -12,7 +12,7 @@ tags: > **Purpose**: Document issues discovered in this project that should be addressed in the [cookiecutter-python-template](https://github.com/ByronWilliamsCPA/cookiecutter-python-template). > > **Generated From**: cookiecutter-python-template v0.1.0 -> **Project Created**: __PROJECT_CREATION_DATE__ +> **Project Created**: **PROJECT_CREATION_DATE** --- diff --git a/mkdocs.yml b/mkdocs.yml index 0750f97..c1a3355 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,3 @@ - # MkDocs Configuration for Python Libs # Documentation: https://www.mkdocs.org/user-guide/configuration/ diff --git a/packages/cloudflare-auth/pyproject.toml b/packages/cloudflare-auth/pyproject.toml index beb84f4..60f7c51 100644 --- a/packages/cloudflare-auth/pyproject.toml +++ b/packages/cloudflare-auth/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", "pyjwt>=2.8.0", "cryptography>=41.0.0", "httpx>=0.25.0", diff --git a/packages/cloudflare-auth/src/cloudflare_auth/__init__.py b/packages/cloudflare-auth/src/cloudflare_auth/__init__.py index 0336f25..d7d888d 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/__init__.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/__init__.py @@ -35,7 +35,11 @@ async def admin(user: CloudflareUser = Depends(require_admin)): return {"message": "Admin access granted"} """ -from cloudflare_auth.middleware import CloudflareAuthMiddleware, get_current_user, get_current_user_optional +from cloudflare_auth.middleware import ( + CloudflareAuthMiddleware, + get_current_user, + get_current_user_optional, +) from cloudflare_auth.middleware_enhanced import ( CloudflareAuthMiddlewareEnhanced, require_admin, @@ -61,38 +65,39 @@ async def admin(user: CloudflareUser = Depends(require_admin)): # Optional Redis session manager (requires redis package) try: from cloudflare_auth.redis_sessions import RedisSessionManager + _REDIS_AVAILABLE = True except ImportError: - RedisSessionManager = None # type: ignore + RedisSessionManager = None # type: ignore[assignment] _REDIS_AVAILABLE = False __all__ = [ + "AuditLogger", # Middleware "CloudflareAuthMiddleware", "CloudflareAuthMiddlewareEnhanced", - "setup_cloudflare_auth_enhanced", - # Models - "CloudflareUser", "CloudflareJWTClaims", - "UserTier", # Validators "CloudflareJWTValidator", + # Models + "CloudflareUser", "EmailWhitelistValidator", + # Security Helpers + "SecurityHeadersMiddleware", # Sessions "SimpleSessionManager", + "UserTier", # Whitelist Management "WhitelistManager", + "create_session_cleanup_task", "create_validator_from_env", - # Security Helpers - "SecurityHeadersMiddleware", - "AuditLogger", "get_audit_logger", - "create_session_cleanup_task", # Dependencies "get_current_user", "get_current_user_optional", "require_admin", "require_tier", + "setup_cloudflare_auth_enhanced", ] # Add RedisSessionManager if available diff --git a/packages/cloudflare-auth/src/cloudflare_auth/config.py b/packages/cloudflare-auth/src/cloudflare_auth/config.py new file mode 100644 index 0000000..c241df3 --- /dev/null +++ b/packages/cloudflare-auth/src/cloudflare_auth/config.py @@ -0,0 +1,210 @@ +"""Configuration settings for Cloudflare Access authentication. + +This module provides Pydantic Settings for configuring Cloudflare Access +authentication middleware. + +Environment Variables: + CLOUDFLARE_TEAM_DOMAIN: Your Cloudflare Access team domain + CLOUDFLARE_AUDIENCE_TAG: Application audience tag from Cloudflare dashboard + CLOUDFLARE_ENABLED: Enable/disable Cloudflare authentication (default: True) + CLOUDFLARE_JWT_HEADER: Header name for JWT token (default: Cf-Access-Jwt-Assertion) + CLOUDFLARE_EMAIL_HEADER: Header name for email (default: Cf-Access-Authenticated-User-Email) + +Example: + from cloudflare_auth.config import get_cloudflare_settings + + settings = get_cloudflare_settings() + print(f"Team domain: {settings.cloudflare_team_domain}") +""" + +from functools import lru_cache +from typing import Literal + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class CloudflareSettings(BaseSettings): + """Configuration for Cloudflare Access authentication. + + This class uses Pydantic Settings to load configuration from environment + variables with sensible defaults for development. + + Attributes: + cloudflare_team_domain: Cloudflare Access team domain (e.g., "myteam") + cloudflare_audience_tag: Application audience tag from CF dashboard + cloudflare_enabled: Whether CF authentication is enabled + jwt_header_name: Header containing the JWT token + email_header_name: Header containing the authenticated email + jwt_algorithm: Algorithm for JWT validation (default: RS256) + jwt_cache_max_keys: Maximum cached signing keys + require_email_verification: Require email claim in token + log_auth_failures: Log failed authentication attempts + require_cloudflare_headers: Require CF-Ray header for validation + allowed_tunnel_ips: List of allowed tunnel IPs (optional) + allowed_email_domains: Restrict to specific email domains + cookie_path: Session cookie path + cookie_secure: Use secure cookies + cookie_samesite: Cookie SameSite attribute + cookie_domain: Cookie domain (optional) + """ + + model_config = SettingsConfigDict( + env_prefix="CLOUDFLARE_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + # Core settings + cloudflare_team_domain: str = Field( + default="", + description="Cloudflare Access team domain", + ) + cloudflare_audience_tag: str = Field( + default="", + description="Application audience tag from Cloudflare dashboard", + ) + cloudflare_enabled: bool = Field( + default=True, + alias="CLOUDFLARE_ENABLED", + description="Enable Cloudflare authentication", + ) + + # Header names + jwt_header_name: str = Field( + default="Cf-Access-Jwt-Assertion", + alias="CLOUDFLARE_JWT_HEADER", + description="Header containing JWT token", + ) + email_header_name: str = Field( + default="Cf-Access-Authenticated-User-Email", + alias="CLOUDFLARE_EMAIL_HEADER", + description="Header containing authenticated email", + ) + + # JWT settings + jwt_algorithm: str = Field( + default="RS256", + description="JWT signing algorithm", + ) + jwt_cache_max_keys: int = Field( + default=16, + description="Maximum number of cached signing keys", + ) + + # Validation settings + require_email_verification: bool = Field( + default=True, + description="Require email claim in JWT", + ) + log_auth_failures: bool = Field( + default=True, + description="Log failed authentication attempts", + ) + require_cloudflare_headers: bool = Field( + default=False, + description="Require CF-Ray header for validation", + ) + + # IP restrictions + allowed_tunnel_ips: list[str] = Field( + default_factory=list, + description="List of allowed tunnel IPs", + ) + + # Email domain restrictions + allowed_email_domains: list[str] = Field( + default_factory=list, + description="Restrict to specific email domains", + ) + + # Cookie settings + cookie_path: str = Field(default="/", description="Session cookie path") + cookie_secure: bool = Field(default=True, description="Use secure cookies") + cookie_samesite: Literal["lax", "strict", "none"] = Field( + default="lax", + description="Cookie SameSite attribute", + ) + cookie_domain: str | None = Field( + default=None, + description="Cookie domain", + ) + + @field_validator("allowed_tunnel_ips", "allowed_email_domains", mode="before") + @classmethod + def parse_comma_separated(cls, v: str | list[str] | None) -> list[str]: + """Parse comma-separated strings into lists.""" + if v is None: + return [] + if isinstance(v, str): + return [x.strip() for x in v.split(",") if x.strip()] + return v + + @property + def certs_url(self) -> str | None: + """Get the Cloudflare certificate URL. + + Returns: + URL for JWKS endpoint, or None if team domain not configured. + """ + if not self.cloudflare_team_domain: + return None + return f"https://{self.cloudflare_team_domain}.cloudflareaccess.com/cdn-cgi/access/certs" + + @property + def issuer(self) -> str | None: + """Get the expected token issuer. + + Returns: + Issuer URL, or None if team domain not configured. + """ + if not self.cloudflare_team_domain: + return None + return f"https://{self.cloudflare_team_domain}.cloudflareaccess.com" + + def is_email_allowed(self, email: str) -> bool: + """Check if an email address is allowed. + + Args: + email: Email address to check + + Returns: + True if email is allowed (no restrictions or matches allowed domains) + """ + if not self.allowed_email_domains: + return True + + email_domain = email.split("@")[-1].lower() if "@" in email else "" + return any( + email_domain == domain.lower().lstrip("@") + for domain in self.allowed_email_domains + ) + + +@lru_cache +def get_cloudflare_settings() -> CloudflareSettings: + """Get cached Cloudflare settings instance. + + This function returns a cached settings instance for efficiency. + Settings are loaded from environment variables and .env file. + + Returns: + CloudflareSettings instance + + Example: + settings = get_cloudflare_settings() + if settings.cloudflare_enabled: + # Configure middleware + pass + """ + return CloudflareSettings() + + +def clear_settings_cache() -> None: + """Clear the cached settings. + + Use this when you need to reload settings, such as after + modifying environment variables in tests. + """ + get_cloudflare_settings.cache_clear() diff --git a/packages/cloudflare-auth/src/cloudflare_auth/csrf.py b/packages/cloudflare-auth/src/cloudflare_auth/csrf.py index 180d089..0030ac7 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/csrf.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/csrf.py @@ -20,7 +20,6 @@ import hashlib import logging import secrets -from typing import Optional logger = logging.getLogger(__name__) @@ -51,7 +50,7 @@ def __init__( self, cookie_name: str = "csrf_token", header_name: str = "X-CSRF-Token", - secret_key: Optional[str] = None, + secret_key: str | None = None, ) -> None: """Initialize CSRF protection. @@ -70,7 +69,7 @@ def __init__( header_name, ) - def generate_token(self, session_id: Optional[str] = None) -> str: + def generate_token(self, session_id: str | None = None) -> str: """Generate a new CSRF token. Args: @@ -80,15 +79,13 @@ def generate_token(self, session_id: Optional[str] = None) -> str: CSRF token string """ # Generate random token - random_bytes = secrets.token_bytes(32) + secrets.token_bytes(32) # Optionally bind to session ID if session_id: # Create HMAC-like token bound to session data = f"{session_id}{secrets.token_hex(16)}".encode() - token = hashlib.sha256( - self.secret_key.encode() + data - ).hexdigest() + token = hashlib.sha256(self.secret_key.encode() + data).hexdigest() else: # Simple random token token = secrets.token_urlsafe(32) @@ -98,8 +95,8 @@ def generate_token(self, session_id: Optional[str] = None) -> str: def validate_token( self, - cookie_token: Optional[str], - header_token: Optional[str], + cookie_token: str | None, + header_token: str | None, constant_time: bool = True, ) -> bool: """Validate CSRF token from cookie and header. @@ -131,8 +128,8 @@ def validate_token( def validate_request( self, - request, - methods_to_protect: set[str] = {"POST", "PUT", "DELETE", "PATCH"}, + request, # noqa: ANN001 - FastAPI/Starlette Request - not annotated to avoid import + methods_to_protect: set[str] | None = None, ) -> bool: """Validate CSRF token for a request. @@ -144,6 +141,8 @@ def validate_request( True if validation passes or not required for this method """ # Skip CSRF check for safe methods + if methods_to_protect is None: + methods_to_protect = {"POST", "PUT", "DELETE", "PATCH"} if request.method not in methods_to_protect: return True @@ -156,7 +155,7 @@ def validate_request( # Global CSRF protection instance -_global_csrf_protection: Optional[CSRFProtection] = None +_global_csrf_protection: CSRFProtection | None = None def get_csrf_protection( @@ -172,7 +171,7 @@ def get_csrf_protection( Returns: CSRFProtection instance """ - global _global_csrf_protection + global _global_csrf_protection # noqa: PLW0603 if _global_csrf_protection is None: _global_csrf_protection = CSRFProtection( diff --git a/packages/cloudflare-auth/src/cloudflare_auth/middleware.py b/packages/cloudflare-auth/src/cloudflare_auth/middleware.py index da4f3dc..5527f85 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/middleware.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/middleware.py @@ -23,9 +23,9 @@ Dependencies: - fastapi: For Request/Response handling - starlette: For BaseHTTPMiddleware - - src.cloudflare_auth.validators: For JWT validation - - src.cloudflare_auth.models: For user models - - src.config.settings: For configuration + - cloudflare_auth.validators: For JWT validation + - cloudflare_auth.models: For user models + - cloudflare_auth.config: For configuration Called by: - FastAPI middleware stack during request processing @@ -52,14 +52,17 @@ async def protected_route(request: Request): from fastapi import HTTPException, Request, Response, status from starlette.middleware.base import BaseHTTPMiddleware -from starlette.responses import JSONResponse +from cloudflare_auth.config import CloudflareSettings, get_cloudflare_settings from cloudflare_auth.models import CloudflareUser from cloudflare_auth.rate_limiter import InMemoryRateLimiter -from cloudflare_auth.utils import get_client_ip, sanitize_email, sanitize_ip, sanitize_path +from cloudflare_auth.utils import ( + get_client_ip, + sanitize_email, + sanitize_ip, + sanitize_path, +) from cloudflare_auth.validators import CloudflareJWTValidator -from src.config.settings import CloudflareSettings, get_cloudflare_settings - logger = logging.getLogger(__name__) @@ -188,7 +191,8 @@ def _validate_cloudflare_origin(self, request: Request) -> None: # Check if IP is in allowlist ip_allowed = any( - client_ip == allowed_ip or client_ip.startswith(allowed_ip.rstrip("/") + ".") + client_ip == allowed_ip + or client_ip.startswith(allowed_ip.rstrip("/") + ".") for allowed_ip in self.settings.allowed_tunnel_ips ) @@ -241,10 +245,8 @@ async def dispatch( # Validate request came through Cloudflare (security check) # This prevents direct access bypassing the tunnel - try: - self._validate_cloudflare_origin(request) - except HTTPException: - raise + # Will raise HTTPException if validation fails + self._validate_cloudflare_origin(request) # Skip authentication if disabled (development mode) if not self.settings.cloudflare_enabled: @@ -269,29 +271,137 @@ async def dispatch( # Re-raise HTTP exceptions raise except Exception as e: - logger.error( - "Unexpected error during authentication: %s", - str(e), - exc_info=True, - ) + logger.exception("Unexpected error during authentication") if self.require_auth: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Authentication service error", ) from e - else: - # Non-required auth: continue without user - request.state.user = None + # Non-required auth: continue without user + request.state.user = None # Process the request return await call_next(request) + def _check_rate_limit(self, request: Request) -> None: + """Check rate limit and raise HTTPException if exceeded.""" + if not (self.enable_rate_limiting and self.rate_limiter): + return + + client_ip = get_client_ip(request) + if self.rate_limiter.is_allowed(client_ip): + return + + retry_after = self.rate_limiter.get_retry_after(client_ip) + logger.warning( + "Rate limit exceeded for IP: %s (path: %s)", + sanitize_ip(client_ip), + sanitize_path(request.url.path), + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many authentication attempts. Please try again later.", + headers={"Retry-After": str(retry_after)}, + ) + + def _handle_missing_token(self, request: Request) -> None: + """Handle missing JWT token - raise if auth required.""" + if not self.require_auth: + return + + self._record_failed_attempt(request) + if self.settings.log_auth_failures: + logger.warning( + "Missing Cloudflare JWT header: %s (path: %s, ip: %s)", + self.settings.jwt_header_name, + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + def _validate_token_size(self, jwt_token: str, request: Request) -> bool: + """Validate JWT token size. Returns True if valid, False otherwise.""" + if len(jwt_token) <= 8192: + return True + + logger.warning( + "SECURITY: JWT token too large: %d bytes (path: %s, ip: %s)", + len(jwt_token), + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + if self.require_auth: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid authentication token", + ) + return False + + def _validate_email_header(self, user: CloudflareUser, request: Request) -> None: + """Validate Cloudflare email header matches JWT claims.""" + email_header = request.headers.get(self.settings.email_header_name) + + if not email_header: + self._record_failed_attempt(request) + logger.error( + "SECURITY: Missing required Cloudflare email header: %s (path: %s, ip: %s)", + self.settings.email_header_name, + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication verification failed", + ) + + if email_header != user.email: + self._record_failed_attempt(request) + logger.error( + "SECURITY: Email mismatch detected - potential token manipulation: " + "JWT=%s, Header=%s, IP=%s", + sanitize_email(user.email), + sanitize_email(email_header), + sanitize_ip(get_client_ip(request)), + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication verification failed", + ) + + def _record_failed_attempt(self, request: Request) -> None: + """Record a failed authentication attempt for rate limiting.""" + if self.enable_rate_limiting and self.rate_limiter: + self.rate_limiter.record_attempt(get_client_ip(request)) + + def _handle_validation_error( + self, error: ValueError, request: Request + ) -> CloudflareUser | None: + """Handle JWT validation errors.""" + self._record_failed_attempt(request) + + if self.settings.log_auth_failures: + logger.warning( + "JWT validation failed: %s (path: %s, ip: %s)", + str(error), + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + + if self.require_auth: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) from error + return None + async def _authenticate_request(self, request: Request) -> CloudflareUser | None: """Authenticate request using Cloudflare headers. - This method extracts the JWT token from request headers, - validates it, and creates a CloudflareUser object. - Args: request: The incoming request @@ -300,130 +410,31 @@ async def _authenticate_request(self, request: Request) -> CloudflareUser | None Raises: HTTPException: If authentication fails and is required - - Called by: - - dispatch(): During request processing """ - # Check rate limit - if self.enable_rate_limiting and self.rate_limiter: - client_ip = get_client_ip(request) - if not self.rate_limiter.is_allowed(client_ip): - retry_after = self.rate_limiter.get_retry_after(client_ip) - logger.warning( - "Rate limit exceeded for IP: %s (path: %s)", - sanitize_ip(client_ip), - sanitize_path(request.url.path), - ) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many authentication attempts. Please try again later.", - headers={"Retry-After": str(retry_after)}, - ) + self._check_rate_limit(request) - # Extract JWT token from header jwt_token = request.headers.get(self.settings.jwt_header_name) - if not jwt_token: - if self.require_auth: - if self.settings.log_auth_failures: - logger.warning( - "Missing Cloudflare JWT header: %s (path: %s, ip: %s)", - self.settings.jwt_header_name, - sanitize_path(request.url.path), - sanitize_ip(get_client_ip(request)), - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing authentication token", - headers={"WWW-Authenticate": "Bearer"}, - ) - else: - # Auth not required, return None - return None + self._handle_missing_token(request) + return None - # SECURITY: Validate JWT token size to prevent DoS attacks - if len(jwt_token) > 8192: # 8KB limit - logger.warning( - "SECURITY: JWT token too large: %d bytes (path: %s, ip: %s)", - len(jwt_token), - sanitize_path(request.url.path), - sanitize_ip(get_client_ip(request)), - ) - if self.require_auth: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid authentication token", - ) + if not self._validate_token_size(jwt_token, request): return None - # Validate the JWT token try: claims = self.validator.validate_token(jwt_token) user = CloudflareUser.from_jwt_claims(claims) - - # Additional email header validation (security check) - # Cloudflare sets this header - we REQUIRE it for security - email_header = request.headers.get(self.settings.email_header_name) - - # CRITICAL SECURITY: Email header must be present when behind Cloudflare - if not email_header: - logger.error( - "SECURITY: Missing required Cloudflare email header: %s (path: %s, ip: %s)", - self.settings.email_header_name, - sanitize_path(request.url.path), - sanitize_ip(get_client_ip(request)), - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication verification failed", - ) - - # Validate email header matches JWT email - if email_header != user.email: - logger.error( - "SECURITY: Email mismatch detected - potential token manipulation: " - "JWT=%s, Header=%s, IP=%s", - sanitize_email(user.email), - sanitize_email(email_header), - sanitize_ip(get_client_ip(request)), - ) - # Always fail on mismatch - this is a security issue - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Authentication verification failed", - ) + self._validate_email_header(user, request) logger.info( "User authenticated successfully: %s (path: %s)", sanitize_email(user.email), sanitize_path(request.url.path), ) - return user except ValueError as e: - # Record failed authentication attempt for rate limiting - if self.enable_rate_limiting and self.rate_limiter: - client_ip = get_client_ip(request) - self.rate_limiter.record_attempt(client_ip) - - if self.settings.log_auth_failures: - logger.warning( - "JWT validation failed: %s (path: %s, ip: %s)", - str(e), - sanitize_path(request.url.path), - sanitize_ip(get_client_ip(request)), - ) - - if self.require_auth: - # Don't leak error details to potential attackers - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication token", - headers={"WWW-Authenticate": "Bearer"}, - ) from e - else: - return None + return self._handle_validation_error(e, request) def setup_cloudflare_auth( diff --git a/packages/cloudflare-auth/src/cloudflare_auth/middleware_enhanced.py b/packages/cloudflare-auth/src/cloudflare_auth/middleware_enhanced.py index a0f666e..890a3cf 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/middleware_enhanced.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/middleware_enhanced.py @@ -36,22 +36,26 @@ ) """ -from collections.abc import Callable import logging +from collections.abc import Callable from typing import Any from fastapi import HTTPException, Request, Response, status from starlette.middleware.base import BaseHTTPMiddleware +from cloudflare_auth.config import CloudflareSettings, get_cloudflare_settings from cloudflare_auth.csrf import CSRFProtection from cloudflare_auth.models import CloudflareUser from cloudflare_auth.rate_limiter import InMemoryRateLimiter from cloudflare_auth.sessions import SimpleSessionManager -from cloudflare_auth.utils import get_client_ip, sanitize_email, sanitize_ip, sanitize_path +from cloudflare_auth.utils import ( + get_client_ip, + sanitize_email, + sanitize_ip, + sanitize_path, +) from cloudflare_auth.validators import CloudflareJWTValidator from cloudflare_auth.whitelist import EmailWhitelistValidator, UserTier -from src.config.settings import CloudflareSettings, get_cloudflare_settings - logger = logging.getLogger(__name__) @@ -131,11 +135,14 @@ def __init__( self.csrf_protection = None # Validate configuration - if self.settings.cloudflare_enabled and require_auth: - if not whitelist_validator: - logger.warning( - "No whitelist validator provided - all authenticated users will be allowed" - ) + if ( + self.settings.cloudflare_enabled + and require_auth + and not whitelist_validator + ): + logger.warning( + "No whitelist validator provided - all authenticated users will be allowed" + ) logger.info( "Initialized enhanced Cloudflare auth middleware " @@ -216,19 +223,153 @@ async def dispatch( except HTTPException: raise except Exception as e: - logger.error( - "Unexpected error during authentication: %s", - str(e), - exc_info=True, - ) + logger.exception("Unexpected error during authentication") if self.require_auth: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Authentication service error", ) from e - else: - request.state.user = None - return await call_next(request) + request.state.user = None + return await call_next(request) + + def _check_rate_limit(self, request: Request) -> None: + """Check rate limit and raise HTTPException if exceeded.""" + if not (self.enable_rate_limiting and self.rate_limiter): + return + + client_ip = get_client_ip(request) + if self.rate_limiter.is_allowed(client_ip): + return + + retry_after = self.rate_limiter.get_retry_after(client_ip) + logger.warning( + "Rate limit exceeded for IP: %s (path: %s)", + sanitize_ip(client_ip), + sanitize_path(request.url.path), + ) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many authentication attempts. Please try again later.", + headers={"Retry-After": str(retry_after)}, + ) + + def _authenticate_from_session(self, request: Request) -> CloudflareUser | None: + """Attempt to authenticate from existing session.""" + if not self.enable_sessions: + return None + + session_id = request.cookies.get("session_id") + if not session_id: + return None + + session = self.session_manager.get_session(session_id) + if not session: + return None + + user = self._user_from_session(session, session_id) + logger.debug("Authenticated from session: %s", user.email) + return user + + def _handle_missing_token(self, request: Request) -> None: + """Handle missing JWT token - raise if auth required.""" + if not self.require_auth: + return + + self._record_failed_attempt(request) + if self.settings.log_auth_failures: + logger.warning( + "Missing JWT header: %s (path: %s, ip: %s)", + self.settings.jwt_header_name, + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + def _validate_token_size(self, jwt_token: str, request: Request) -> bool: + """Validate JWT token size. Returns True if valid.""" + if len(jwt_token) <= 8192: + return True + + logger.warning( + "JWT token too large: %d bytes (path: %s, ip: %s)", + len(jwt_token), + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + if self.require_auth: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="JWT token too large", + ) + return False + + def _record_failed_attempt(self, request: Request) -> None: + """Record a failed authentication attempt for rate limiting.""" + if self.enable_rate_limiting and self.rate_limiter: + self.rate_limiter.record_attempt(get_client_ip(request)) + + def _handle_jwt_validation_error(self, error: ValueError, request: Request) -> None: + """Handle JWT validation errors.""" + self._record_failed_attempt(request) + + if self.settings.log_auth_failures: + logger.warning( + "JWT validation failed: %s (path: %s, ip: %s)", + str(error), + sanitize_path(request.url.path), + sanitize_ip(get_client_ip(request)), + ) + + if self.require_auth: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication token", + headers={"WWW-Authenticate": "Bearer"}, + ) from error + + def _check_whitelist(self, email: str) -> UserTier: + """Check whitelist authorization and return user tier.""" + if not self.whitelist_validator: + return UserTier.FULL + + if not self.whitelist_validator.is_authorized(email): + logger.warning( + "Unauthorized email attempted access: %s", + sanitize_email(email), + ) + if self.require_auth: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Email {email} not authorized", + ) + msg = "Email not authorized" + raise ValueError(msg) + + try: + return self.whitelist_validator.get_user_tier(email) + except ValueError: + return UserTier.LIMITED + + def _create_session_if_enabled( + self, claims: Any, user_tier: UserTier, request: Request + ) -> str | None: + """Create session if sessions are enabled.""" + if not self.enable_sessions: + return None + + return self.session_manager.create_session( + email=claims.email, + is_admin=user_tier.has_admin_privileges, + user_tier=user_tier.value, + cf_context={ + "cf_ray": request.headers.get("cf-ray"), + "cf_country": request.headers.get("cf-ipcountry"), + }, + ) async def _authenticate_request(self, request: Request) -> CloudflareUser | None: """Authenticate request using JWT and whitelist. @@ -242,128 +383,37 @@ async def _authenticate_request(self, request: Request) -> CloudflareUser | None Raises: HTTPException: If authentication fails and is required """ - # Check rate limit - if self.enable_rate_limiting and self.rate_limiter: - client_ip = get_client_ip(request) - if not self.rate_limiter.is_allowed(client_ip): - retry_after = self.rate_limiter.get_retry_after(client_ip) - logger.warning( - "Rate limit exceeded for IP: %s (path: %s)", - sanitize_ip(client_ip), - sanitize_path(request.url.path), - ) - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="Too many authentication attempts. Please try again later.", - headers={"Retry-After": str(retry_after)}, - ) + self._check_rate_limit(request) # Check for existing session first - if self.enable_sessions: - session_id = request.cookies.get("session_id") - if session_id: - session = self.session_manager.get_session(session_id) - if session: - # Recreate user from session - user = self._user_from_session(session, session_id) - logger.debug("Authenticated from session: %s", user.email) - return user + session_user = self._authenticate_from_session(request) + if session_user: + return session_user # Extract JWT token jwt_token = request.headers.get(self.settings.jwt_header_name) - if not jwt_token: - if self.require_auth: - if self.settings.log_auth_failures: - logger.warning( - "Missing JWT header: %s (path: %s, ip: %s)", - self.settings.jwt_header_name, - sanitize_path(request.url.path), - sanitize_ip(get_client_ip(request)), - ) - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Missing authentication token", - headers={"WWW-Authenticate": "Bearer"}, - ) + self._handle_missing_token(request) return None - # Security: Validate JWT token size (prevent DoS) - if len(jwt_token) > 8192: # 8KB limit - logger.warning( - "JWT token too large: %d bytes (path: %s, ip: %s)", - len(jwt_token), - request.url.path, - self._get_client_ip(request), - ) - if self.require_auth: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="JWT token too large", - ) + if not self._validate_token_size(jwt_token, request): return None # Validate JWT token try: claims = self.jwt_validator.validate_token(jwt_token) except ValueError as e: - # Record failed authentication attempt for rate limiting - if self.enable_rate_limiting and self.rate_limiter: - client_ip = get_client_ip(request) - self.rate_limiter.record_attempt(client_ip) - - if self.settings.log_auth_failures: - logger.warning( - "JWT validation failed: %s (path: %s, ip: %s)", - str(e), - sanitize_path(request.url.path), - sanitize_ip(get_client_ip(request)), - ) + self._handle_jwt_validation_error(e, request) + return None - if self.require_auth: - # Don't leak error details to potential attackers - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid authentication token", - headers={"WWW-Authenticate": "Bearer"}, - ) from e + # Check whitelist and get tier + try: + user_tier = self._check_whitelist(claims.email) + except ValueError: return None - # Check whitelist if configured - if self.whitelist_validator: - if not self.whitelist_validator.is_authorized(claims.email): - logger.warning( - "Unauthorized email attempted access: %s", - sanitize_email(claims.email), - ) - if self.require_auth: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=f"Email {claims.email} not authorized", - ) - return None - - # Get user tier - try: - user_tier = self.whitelist_validator.get_user_tier(claims.email) - except ValueError: - user_tier = UserTier.LIMITED - else: - # No whitelist - default to full access - user_tier = UserTier.FULL - - # Create or update session - session_id = None - if self.enable_sessions: - session_id = self.session_manager.create_session( - email=claims.email, - is_admin=user_tier.has_admin_privileges, - user_tier=user_tier.value, - cf_context={ - "cf_ray": request.headers.get("cf-ray"), - "cf_country": request.headers.get("cf-ipcountry"), - }, - ) + # Create session if enabled + session_id = self._create_session_if_enabled(claims, user_tier, request) # Create user object user = CloudflareUser.from_jwt_claims( @@ -383,9 +433,7 @@ async def _authenticate_request(self, request: Request) -> CloudflareUser | None return user def _user_from_session( - self, - session: dict[str, Any], - session_id: str + self, session: dict[str, Any], session_id: str ) -> CloudflareUser: """Recreate CloudflareUser from session data. @@ -405,7 +453,8 @@ def _user_from_session( aud=[self.settings.cloudflare_audience_tag], sub=session.get("email", ""), iat=int(session["created_at"].timestamp()), - exp=int(session["last_accessed"].timestamp()) + self.session_manager.session_timeout, + exp=int(session["last_accessed"].timestamp()) + + self.session_manager.session_timeout, ) tier = UserTier.from_string(session.get("user_tier", "limited")) @@ -667,6 +716,7 @@ def require_tier(minimum_tier: UserTier) -> Callable: async def premium(user: CloudflareUser = Depends(require_full)): return {"message": "Premium content"} """ + def dependency(request: Request) -> CloudflareUser: user = get_current_user(request) diff --git a/packages/cloudflare-auth/src/cloudflare_auth/models.py b/packages/cloudflare-auth/src/cloudflare_auth/models.py index deefb9a..4cb5426 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/models.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/models.py @@ -17,7 +17,7 @@ - Application endpoints: For accessing user information """ -from datetime import datetime +from datetime import UTC, datetime from typing import Any from pydantic import BaseModel, EmailStr, Field @@ -54,37 +54,18 @@ class CloudflareJWTClaims(BaseModel): """ # Standard JWT claims - email: EmailStr = Field( - description="Authenticated user's email address" - ) - iss: str = Field( - description="Token issuer (Cloudflare team domain)" - ) - aud: list[str] | str = Field( - description="Audience tag(s) for the application" - ) - sub: str = Field( - description="Subject (user identifier)" - ) - iat: int = Field( - description="Issued at timestamp (Unix epoch)" - ) - exp: int = Field( - description="Expiration timestamp (Unix epoch)" - ) + email: EmailStr = Field(description="Authenticated user's email address") + iss: str = Field(description="Token issuer (Cloudflare team domain)") + aud: list[str] | str = Field(description="Audience tag(s) for the application") + sub: str = Field(description="Subject (user identifier)") + iat: int = Field(description="Issued at timestamp (Unix epoch)") + exp: int = Field(description="Expiration timestamp (Unix epoch)") # Optional Cloudflare-specific claims - nonce: str | None = Field( - default=None, - description="Nonce for replay protection" - ) - identity_nonce: str | None = Field( - default=None, - description="Identity nonce" - ) + nonce: str | None = Field(default=None, description="Nonce for replay protection") + identity_nonce: str | None = Field(default=None, description="Identity nonce") custom: dict[str, Any] = Field( - default_factory=dict, - description="Custom claims from identity provider" + default_factory=dict, description="Custom claims from identity provider" ) @property @@ -94,7 +75,7 @@ def issued_at(self) -> datetime: Returns: Datetime when token was issued """ - return datetime.fromtimestamp(self.iat) + return datetime.fromtimestamp(self.iat, tz=UTC) @property def expires_at(self) -> datetime: @@ -103,7 +84,7 @@ def expires_at(self) -> datetime: Returns: Datetime when token expires """ - return datetime.fromtimestamp(self.exp) + return datetime.fromtimestamp(self.exp, tz=UTC) @property def is_expired(self) -> bool: @@ -112,7 +93,7 @@ def is_expired(self) -> bool: Returns: True if token is expired """ - return datetime.now() >= self.expires_at + return datetime.now(tz=UTC) >= self.expires_at def get_audience_list(self) -> list[str]: """Get audience as a list. @@ -161,30 +142,21 @@ async def get_me(request: Request) -> dict: } """ - email: EmailStr = Field( - description="Authenticated user's email address" - ) - user_id: str = Field( - description="Unique user identifier" - ) - claims: CloudflareJWTClaims = Field( - description="Full JWT claims from Cloudflare" - ) + email: EmailStr = Field(description="Authenticated user's email address") + user_id: str = Field(description="Unique user identifier") + claims: CloudflareJWTClaims = Field(description="Full JWT claims from Cloudflare") authenticated_at: datetime = Field( default_factory=datetime.now, - description="Timestamp when user was authenticated" + description="Timestamp when user was authenticated", ) user_tier: UserTier = Field( - default=UserTier.LIMITED, - description="User's access tier" + default=UserTier.LIMITED, description="User's access tier" ) is_admin: bool = Field( - default=False, - description="Whether user has admin privileges" + default=False, description="Whether user has admin privileges" ) session_id: str | None = Field( - default=None, - description="Session identifier if sessions are enabled" + default=None, description="Session identifier if sessions are enabled" ) @classmethod diff --git a/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py b/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py index 5a220e2..3b0c1af 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/rate_limiter.py @@ -21,11 +21,10 @@ - src.cloudflare_auth.middleware: For authentication rate limiting """ +import logging from collections import defaultdict -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from threading import Lock -from typing import Dict, Tuple -import logging logger = logging.getLogger(__name__) @@ -72,9 +71,9 @@ def __init__( self.cleanup_interval = cleanup_interval # Store: IP -> list of attempt timestamps - self.attempts: Dict[str, list[datetime]] = defaultdict(list) + self.attempts: dict[str, list[datetime]] = defaultdict(list) self.lock = Lock() - self.last_cleanup = datetime.now() + self.last_cleanup = datetime.now(tz=UTC) logger.info( "Initialized rate limiter: %d attempts per %d seconds", @@ -94,14 +93,15 @@ def is_allowed(self, identifier: str) -> bool: with self.lock: self._cleanup_if_needed() - current_time = datetime.now() + current_time = datetime.now(tz=UTC) cutoff_time = current_time - timedelta(seconds=self.window_seconds) # Get attempts within window if identifier in self.attempts: # Remove expired attempts self.attempts[identifier] = [ - timestamp for timestamp in self.attempts[identifier] + timestamp + for timestamp in self.attempts[identifier] if timestamp > cutoff_time ] @@ -124,7 +124,7 @@ def record_attempt(self, identifier: str) -> None: identifier: IP address or other identifier """ with self.lock: - self.attempts[identifier].append(datetime.now()) + self.attempts[identifier].append(datetime.now(tz=UTC)) def reset(self, identifier: str) -> None: """Reset rate limit for an identifier. @@ -147,7 +147,7 @@ def get_remaining_attempts(self, identifier: str) -> int: Number of remaining attempts """ with self.lock: - current_time = datetime.now() + current_time = datetime.now(tz=UTC) cutoff_time = current_time - timedelta(seconds=self.window_seconds) if identifier not in self.attempts: @@ -155,7 +155,8 @@ def get_remaining_attempts(self, identifier: str) -> int: # Count recent attempts recent_attempts = [ - timestamp for timestamp in self.attempts[identifier] + timestamp + for timestamp in self.attempts[identifier] if timestamp > cutoff_time ] @@ -174,12 +175,13 @@ def get_retry_after(self, identifier: str) -> int: if identifier not in self.attempts or not self.attempts[identifier]: return 0 - current_time = datetime.now() + current_time = datetime.now(tz=UTC) cutoff_time = current_time - timedelta(seconds=self.window_seconds) # Find oldest attempt in window recent_attempts = [ - timestamp for timestamp in self.attempts[identifier] + timestamp + for timestamp in self.attempts[identifier] if timestamp > cutoff_time ] @@ -198,7 +200,7 @@ def _cleanup_if_needed(self) -> None: Note: Must be called while holding self.lock """ - current_time = datetime.now() + current_time = datetime.now(tz=UTC) if (current_time - self.last_cleanup).total_seconds() < self.cleanup_interval: return @@ -208,9 +210,7 @@ def _cleanup_if_needed(self) -> None: for identifier, timestamps in self.attempts.items(): # Remove old timestamps - self.attempts[identifier] = [ - ts for ts in timestamps if ts > cutoff_time - ] + self.attempts[identifier] = [ts for ts in timestamps if ts > cutoff_time] # Mark empty entries for removal if not self.attempts[identifier]: @@ -224,8 +224,7 @@ def _cleanup_if_needed(self) -> None: if identifiers_to_remove: logger.debug( - "Cleaned up %d expired rate limit entries", - len(identifiers_to_remove) + "Cleaned up %d expired rate limit entries", len(identifiers_to_remove) ) def get_stats(self) -> dict: @@ -236,7 +235,9 @@ def get_stats(self) -> dict: """ with self.lock: total_tracked = len(self.attempts) - total_attempts = sum(len(timestamps) for timestamps in self.attempts.values()) + total_attempts = sum( + len(timestamps) for timestamps in self.attempts.values() + ) return { "tracked_identifiers": total_tracked, @@ -264,7 +265,7 @@ def get_rate_limiter( Returns: InMemoryRateLimiter instance """ - global _global_rate_limiter + global _global_rate_limiter # noqa: PLW0603 if _global_rate_limiter is None: _global_rate_limiter = InMemoryRateLimiter( diff --git a/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py b/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py index 971afd5..2c20afe 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py @@ -37,15 +37,16 @@ import json import logging import secrets -from datetime import datetime +from datetime import UTC, datetime from typing import Any try: import redis + REDIS_AVAILABLE = True except ImportError: REDIS_AVAILABLE = False - redis = None # type: ignore + redis = None # type: ignore[assignment] logger = logging.getLogger(__name__) @@ -95,10 +96,11 @@ def __init__( redis.ConnectionError: If cannot connect to Redis """ if not REDIS_AVAILABLE or redis is None: - raise ImportError( + msg = ( "Redis package is required for RedisSessionManager. " "Install with: pip install redis>=5.0.0" ) + raise ImportError(msg) self.session_timeout = session_timeout self.key_prefix = key_prefix @@ -119,8 +121,8 @@ def __init__( session_timeout, key_prefix, ) - except redis.ConnectionError as e: - logger.error("Failed to connect to Redis: %s", e) + except redis.ConnectionError: + logger.exception("Failed to connect to Redis") raise def _make_key(self, session_id: str) -> str: @@ -168,18 +170,14 @@ def create_session( "email": email, "is_admin": is_admin, "user_tier": user_tier, - "created_at": datetime.now().isoformat(), - "last_accessed": datetime.now().isoformat(), + "created_at": datetime.now(tz=UTC).isoformat(), + "last_accessed": datetime.now(tz=UTC).isoformat(), "cf_context": cf_context or {}, } # Store in Redis with TTL key = self._make_key(session_id) - self.redis_client.setex( - key, - self.session_timeout, - json.dumps(session_data) - ) + self.redis_client.setex(key, self.session_timeout, json.dumps(session_data)) logger.debug( "Created session for %s (tier: %s, admin: %s)", @@ -218,23 +216,23 @@ def get_session(self, session_id: str) -> dict[str, Any] | None: session_data = json.loads(session_data_json) # Update last accessed timestamp - session_data["last_accessed"] = datetime.now().isoformat() + session_data["last_accessed"] = datetime.now(tz=UTC).isoformat() # Update in Redis and refresh TTL - self.redis_client.setex( - key, - self.session_timeout, - json.dumps(session_data) - ) + self.redis_client.setex(key, self.session_timeout, json.dumps(session_data)) # Parse datetime objects - session_data["created_at"] = datetime.fromisoformat(session_data["created_at"]) - session_data["last_accessed"] = datetime.fromisoformat(session_data["last_accessed"]) + session_data["created_at"] = datetime.fromisoformat( + session_data["created_at"] + ) + session_data["last_accessed"] = datetime.fromisoformat( + session_data["last_accessed"] + ) return session_data except (json.JSONDecodeError, ValueError) as e: - logger.error("Failed to decode session data: %s", e) + logger.exception("Failed to decode session data: %s", e) # Delete corrupted session self.redis_client.delete(key) return None diff --git a/packages/cloudflare-auth/src/cloudflare_auth/security_helpers.py b/packages/cloudflare-auth/src/cloudflare_auth/security_helpers.py index 6ec5ae9..d96205c 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/security_helpers.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/security_helpers.py @@ -25,7 +25,6 @@ from cloudflare_auth.sessions import SimpleSessionManager - logger = logging.getLogger(__name__) @@ -151,12 +150,10 @@ async def startup(): async def shutdown(): app.state.cleanup_task.cancel() """ - async def cleanup_loop(): + + async def cleanup_loop() -> None: """Background loop for session cleanup.""" - logger.info( - "Session cleanup task started (interval: %ds)", - cleanup_interval - ) + logger.info("Session cleanup task started (interval: %ds)", cleanup_interval) try: while True: await asyncio.sleep(cleanup_interval) @@ -233,7 +230,7 @@ def log_admin_action( "target": target, "result": result, "details": details or {}, - } + }, ) def log_auth_event( @@ -266,7 +263,7 @@ def log_auth_event( "ip": ip_address, "result": result, "details": details or {}, - } + }, ) def log_access_denied( @@ -296,7 +293,7 @@ def log_access_denied( "resource": resource, "reason": reason, "ip": ip_address, - } + }, ) def log_security_event( @@ -332,7 +329,7 @@ def log_security_event( "severity": severity, "description": description, "details": details or {}, - } + }, ) diff --git a/packages/cloudflare-auth/src/cloudflare_auth/sessions.py b/packages/cloudflare-auth/src/cloudflare_auth/sessions.py index 4698248..a473ddb 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/sessions.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/sessions.py @@ -17,9 +17,9 @@ - src.cloudflare_auth.middleware: For session management during authentication """ -from datetime import datetime, timedelta import logging import secrets +from datetime import UTC, datetime, timedelta from typing import Any logger = logging.getLogger(__name__) @@ -101,8 +101,8 @@ def create_session( "email": email, "is_admin": is_admin, "user_tier": user_tier, - "created_at": datetime.now(), - "last_accessed": datetime.now(), + "created_at": datetime.now(tz=UTC), + "last_accessed": datetime.now(tz=UTC), "cf_context": cf_context or {}, } @@ -143,15 +143,12 @@ def get_session(self, session_id: str) -> dict[str, Any] | None: # Check if session is expired if self._is_session_expired(session): - logger.debug( - "Session %s expired, removing", - session_id[:8] + "..." - ) + logger.debug("Session %s expired, removing", session_id[:8] + "...") del self.sessions[session_id] return None # Update last accessed time - session["last_accessed"] = datetime.now() + session["last_accessed"] = datetime.now(tz=UTC) return session def invalidate_session(self, session_id: str) -> bool: @@ -171,11 +168,7 @@ def invalidate_session(self, session_id: str) -> bool: if session_id in self.sessions: email = self.sessions[session_id].get("email", "unknown") del self.sessions[session_id] - logger.debug( - "Invalidated session %s for %s", - session_id[:8] + "...", - email - ) + logger.debug("Invalidated session %s for %s", session_id[:8] + "...", email) return True return False @@ -190,7 +183,7 @@ def refresh_session(self, session_id: str) -> bool: """ session = self.sessions.get(session_id) if session: - session["last_accessed"] = datetime.now() + session["last_accessed"] = datetime.now(tz=UTC) return True return False @@ -204,7 +197,7 @@ def _is_session_expired(self, session: dict[str, Any]) -> bool: True if session has exceeded timeout """ expiry = session["last_accessed"] + timedelta(seconds=self.session_timeout) - return datetime.now() >= expiry + return datetime.now(tz=UTC) >= expiry def cleanup_expired_sessions(self) -> int: """Remove expired sessions from memory. @@ -282,7 +275,9 @@ def get_session_info(self, session_id: str) -> dict[str, Any] | None: "user_tier": session["user_tier"], "created_at": session["created_at"].isoformat(), "last_accessed": session["last_accessed"].isoformat(), - "age_seconds": (datetime.now() - session["created_at"]).total_seconds(), + "age_seconds": ( + datetime.now(tz=UTC) - session["created_at"] + ).total_seconds(), } def get_stats(self) -> dict[str, Any]: @@ -291,7 +286,7 @@ def get_stats(self) -> dict[str, Any]: Returns: Dictionary with session statistics """ - now = datetime.now() + datetime.now(tz=UTC) active_sessions = [] expired_sessions = [] diff --git a/packages/cloudflare-auth/src/cloudflare_auth/utils.py b/packages/cloudflare-auth/src/cloudflare_auth/utils.py index 5ab30ce..bd83c53 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/utils.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/utils.py @@ -18,12 +18,11 @@ """ import re -from typing import Any, Optional - +from typing import Any # Patterns for dangerous characters in logs -CONTROL_CHARS_PATTERN = re.compile(r'[\x00-\x1f\x7f-\x9f]') -NEWLINE_PATTERN = re.compile(r'[\r\n]') +CONTROL_CHARS_PATTERN = re.compile(r"[\x00-\x1f\x7f-\x9f]") +NEWLINE_PATTERN = re.compile(r"[\r\n]") def sanitize_for_logging( @@ -32,7 +31,7 @@ def sanitize_for_logging( replace_newlines: bool = True, replace_control_chars: bool = True, ) -> str: - """Sanitize user input for safe logging. + r"""Sanitize user input for safe logging. This function prevents log injection by: - Removing or replacing newline characters @@ -66,11 +65,11 @@ def sanitize_for_logging( # Remove/replace newlines to prevent log injection if replace_newlines: - str_value = NEWLINE_PATTERN.sub(' ', str_value) + str_value = NEWLINE_PATTERN.sub(" ", str_value) # Remove/replace control characters if replace_control_chars: - str_value = CONTROL_CHARS_PATTERN.sub('�', str_value) + str_value = CONTROL_CHARS_PATTERN.sub("�", str_value) # Truncate if too long if len(str_value) > max_length: @@ -82,7 +81,7 @@ def sanitize_for_logging( def sanitize_email(email: str, max_length: int = 254) -> str: - """Sanitize email address for logging. + r"""Sanitize email address for logging. Validates email format and sanitizes for safe logging. @@ -102,14 +101,14 @@ def sanitize_email(email: str, max_length: int = 254) -> str: sanitized = sanitize_for_logging(email, max_length=max_length) # Ensure it still looks like an email - if '@' not in sanitized: + if "@" not in sanitized: return "" return sanitized def sanitize_path(path: str, max_length: int = 200) -> str: - """Sanitize URL path for logging. + r"""Sanitize URL path for logging. Args: path: URL path to sanitize @@ -128,7 +127,7 @@ def sanitize_path(path: str, max_length: int = 200) -> str: def sanitize_ip(ip: str, max_length: int = 45) -> str: - """Sanitize IP address for logging. + r"""Sanitize IP address for logging. Args: ip: IP address to sanitize @@ -151,7 +150,7 @@ def sanitize_ip(ip: str, max_length: int = 45) -> str: # Remove any remaining suspicious characters # IPs should only contain digits, dots, colons (IPv6), and maybe brackets - allowed_pattern = re.compile(r'^[0-9a-fA-F:.[\]]+$') + allowed_pattern = re.compile(r"^[0-9a-fA-F:.[\]]+$") if not allowed_pattern.match(sanitized): return "" @@ -161,7 +160,7 @@ def sanitize_ip(ip: str, max_length: int = 45) -> str: def sanitize_dict_for_logging( data: dict[str, Any], max_value_length: int = 100, - excluded_keys: Optional[set[str]] = None, + excluded_keys: set[str] | None = None, ) -> dict[str, str]: """Sanitize dictionary for safe logging. @@ -174,13 +173,23 @@ def sanitize_dict_for_logging( Sanitized dictionary with string values Example: - >>> sanitize_dict_for_logging({"email": "user@example.com", "password": "secret"}, excluded_keys={"password"}) + >>> sanitize_dict_for_logging( + ... {"email": "user@example.com", "password": "secret"}, + ... excluded_keys={"password"}, + ... ) {'email': 'user@example.com', 'password': ''} """ if excluded_keys is None: excluded_keys = { - 'password', 'token', 'secret', 'key', 'api_key', - 'access_token', 'refresh_token', 'jwt', 'authorization' + "password", + "token", + "secret", + "key", + "api_key", + "access_token", + "refresh_token", + "jwt", + "authorization", } sanitized = {} @@ -188,14 +197,16 @@ def sanitize_dict_for_logging( # Check if key should be excluded key_lower = key.lower() if any(excluded in key_lower for excluded in excluded_keys): - sanitized[key] = '' + sanitized[key] = "" else: sanitized[key] = sanitize_for_logging(value, max_length=max_value_length) return sanitized -def mask_sensitive_data(text: str, pattern: str = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b') -> str: +def mask_sensitive_data( + text: str, pattern: str = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b" +) -> str: """Mask sensitive data in text using regex pattern. Args: @@ -209,13 +220,14 @@ def mask_sensitive_data(text: str, pattern: str = r'\b[A-Za-z0-9._%+-]+@[A-Za-z0 >>> mask_sensitive_data("Contact user@example.com for help") 'Contact ***@***.*** for help' """ + def mask_match(match): matched = match.group(0) - if '@' in matched: + if "@" in matched: # Email-like pattern - local, domain = matched.split('@', 1) + local, domain = matched.split("@", 1) return f"{'*' * min(len(local), 3)}@{'*' * min(len(domain), 3)}.***" - return '*' * len(matched) + return "*" * len(matched) return re.sub(pattern, mask_match, text) diff --git a/packages/cloudflare-auth/src/cloudflare_auth/validators.py b/packages/cloudflare-auth/src/cloudflare_auth/validators.py index 89261ca..1eeffc5 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/validators.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/validators.py @@ -29,16 +29,14 @@ """ import logging -from datetime import datetime, timedelta +from datetime import UTC, datetime from typing import Any -import httpx import jwt from jwt import PyJWKClient +from cloudflare_auth.config import CloudflareSettings, get_cloudflare_settings from cloudflare_auth.models import CloudflareJWTClaims -from src.config.settings import CloudflareSettings, get_cloudflare_settings - logger = logging.getLogger(__name__) @@ -135,9 +133,8 @@ def validate_token( logger.error(f"Auth failed: {e}") """ if not self.jwks_client: - raise RuntimeError( - "JWT validator not configured. Set CLOUDFLARE_TEAM_DOMAIN." - ) + msg = "JWT validator not configured. Set CLOUDFLARE_TEAM_DOMAIN." + raise RuntimeError(msg) try: # Get the signing key from the JWT header @@ -165,13 +162,13 @@ def validate_token( # Additional validation if self.settings.require_email_verification and not claims.email: - raise ValueError("Email claim is required but missing") + msg = "Email claim is required but missing" + raise ValueError(msg) # Check email domain if restrictions are configured if not self.settings.is_email_allowed(claims.email): - raise ValueError( - f"Email domain not allowed: {claims.email}" - ) + msg = f"Email domain not allowed: {claims.email}" + raise ValueError(msg) logger.debug( "Successfully validated JWT for user: %s", @@ -182,27 +179,33 @@ def validate_token( except jwt.ExpiredSignatureError as e: logger.warning("JWT token expired: %s", str(e)) - raise ValueError("Token has expired") from e + msg = "Token has expired" + raise ValueError(msg) from e except jwt.InvalidAudienceError as e: logger.warning("Invalid JWT audience: %s", str(e)) - raise ValueError("Invalid token audience") from e + msg = "Invalid token audience" + raise ValueError(msg) from e except jwt.InvalidIssuerError as e: logger.warning("Invalid JWT issuer: %s", str(e)) - raise ValueError("Invalid token issuer") from e + msg = "Invalid token issuer" + raise ValueError(msg) from e except jwt.InvalidSignatureError as e: logger.warning("Invalid JWT signature: %s", str(e)) - raise ValueError("Invalid token signature") from e + msg = "Invalid token signature" + raise ValueError(msg) from e except jwt.DecodeError as e: logger.warning("Failed to decode JWT: %s", str(e)) - raise ValueError("Invalid token format") from e + msg = "Invalid token format" + raise ValueError(msg) from e except Exception as e: - logger.error("Unexpected error validating JWT: %s", str(e)) - raise ValueError(f"Token validation failed: {str(e)}") from e + logger.exception("Unexpected error validating JWT: %s", str(e)) + msg = f"Token validation failed: {e!s}" + raise ValueError(msg) from e def _validate_required_claims(self, payload: dict[str, Any]) -> None: """Validate that required claims are present. @@ -215,14 +218,11 @@ def _validate_required_claims(self, payload: dict[str, Any]) -> None: """ required_claims = ["email", "iss", "aud", "sub", "iat", "exp"] - missing_claims = [ - claim for claim in required_claims if claim not in payload - ] + missing_claims = [claim for claim in required_claims if claim not in payload] if missing_claims: - raise ValueError( - f"Missing required JWT claims: {', '.join(missing_claims)}" - ) + msg = f"Missing required JWT claims: {', '.join(missing_claims)}" + raise ValueError(msg) async def validate_token_async( self, @@ -267,7 +267,7 @@ def refresh_keys(self) -> None: cache_keys=True, max_cached_keys=self.settings.jwt_cache_max_keys, ) - self._last_key_refresh = datetime.now() + self._last_key_refresh = datetime.now(tz=UTC) logger.info("Cloudflare public keys client refreshed") @property @@ -306,5 +306,5 @@ def get_unverified_claims(self, token: str) -> dict[str, Any]: options={"verify_signature": False}, ) except Exception as e: - logger.error("Failed to decode token: %s", str(e)) + logger.exception("Failed to decode token: %s", str(e)) return {} diff --git a/packages/cloudflare-auth/src/cloudflare_auth/whitelist.py b/packages/cloudflare-auth/src/cloudflare_auth/whitelist.py index eeef46f..54da6b7 100644 --- a/packages/cloudflare-auth/src/cloudflare_auth/whitelist.py +++ b/packages/cloudflare-auth/src/cloudflare_auth/whitelist.py @@ -20,16 +20,17 @@ - Application code: For access control decisions """ -from dataclasses import dataclass -from enum import Enum import logging import secrets +from dataclasses import dataclass +from enum import Enum from typing import Any from pydantic import BaseModel, field_validator try: - from email_validator import validate_email, EmailNotValidError + from email_validator import EmailNotValidError, validate_email + EMAIL_VALIDATOR_AVAILABLE = True except ImportError: EMAIL_VALIDATOR_AVAILABLE = False @@ -69,7 +70,8 @@ def from_string(cls, value: str) -> "UserTier": for tier in cls: if tier.value == value: return tier - raise ValueError(f"Invalid user tier: {value}") + msg = f"Invalid user tier: {value}" + raise ValueError(msg) @property def can_access_premium_models(self) -> bool: @@ -127,7 +129,9 @@ class EmailWhitelistConfig(BaseModel): limited_users: list[str] = [] case_sensitive: bool = False - @field_validator("whitelist", "admin_emails", "full_users", "limited_users", mode="before") + @field_validator( + "whitelist", "admin_emails", "full_users", "limited_users", mode="before" + ) @classmethod def normalize_emails(cls, v: str | list[str]) -> list[str]: """Normalize email addresses to lowercase unless case_sensitive. @@ -267,7 +271,9 @@ def is_authorized(self, email: str) -> bool: domain = "@" + normalized_email.split("@")[1] for allowed_domain in self.domain_patterns: if secrets.compare_digest(domain, allowed_domain): - logger.debug("Email %s authorized via domain pattern %s", email, domain) + logger.debug( + "Email %s authorized via domain pattern %s", email, domain + ) return True logger.debug("Email %s not authorized", email) @@ -327,10 +333,12 @@ def get_user_tier(self, email: str) -> UserTier: ValueError: If email is not authorized """ if not email: - raise ValueError("Email cannot be empty") + msg = "Email cannot be empty" + raise ValueError(msg) if not self.is_authorized(email): - raise ValueError(f"Email {email} is not authorized") + msg = f"Email {email} is not authorized" + raise ValueError(msg) normalized_email = self._normalize_email(email) @@ -341,7 +349,9 @@ def get_user_tier(self, email: str) -> UserTier: # First check exact email matches for tier_list, tier_type in zip(tier_lists, tier_types, strict=False): if normalized_email in tier_list: - logger.debug("Email %s has %s tier (exact match)", email, tier_type.value) + logger.debug( + "Email %s has %s tier (exact match)", email, tier_type.value + ) return tier_type # Then check domain pattern matches @@ -413,51 +423,85 @@ def get_whitelist_stats(self) -> dict[str, Any]: }, } - def validate_whitelist_config(self) -> list[str]: - """Validate the whitelist configuration and return any warnings. + def _check_empty_whitelist(self) -> list[str]: + """Check if whitelist is empty. Returns: - List of warning messages about the configuration + List of warning messages if whitelist is empty, empty list otherwise. """ - warnings = [] - - # Check for empty whitelist if not self.individual_emails and not self.domain_patterns: - warnings.append("Whitelist is empty - no users will be authorized") + return ["Whitelist is empty - no users will be authorized"] + return [] - # Check for admin emails not in whitelist - for admin_email in self.admin_emails: - if not self.is_authorized(admin_email): - warnings.append(f"Admin email {admin_email} is not in whitelist") + def _check_tier_authorization(self, emails: list[str], tier_name: str) -> list[str]: + """Check if tier emails are authorized in whitelist. - # Check for full users not in whitelist - for full_user in self.full_users: - if not self.is_authorized(full_user): - warnings.append(f"Full user email {full_user} is not in whitelist") + Args: + emails: List of email addresses to check. + tier_name: Name of the tier for warning messages. - # Check for limited users not in whitelist - for limited_user in self.limited_users: - if not self.is_authorized(limited_user): - warnings.append(f"Limited user email {limited_user} is not in whitelist") + Returns: + List of warning messages for unauthorized emails. + """ + warnings = [] + for email in emails: + if not self.is_authorized(email): + warnings.append(f"{tier_name} email {email} is not in whitelist") + return warnings - # Check for tier conflicts - all_tier_emails = set(self.admin_emails) | set(self.full_users) | set(self.limited_users) - for email in all_tier_emails: - tiers = [] - if email in self.admin_emails: - tiers.append("admin") - if email in self.full_users: - tiers.append("full") - if email in self.limited_users: - tiers.append("limited") + def _check_tier_conflicts(self) -> list[str]: + """Check for emails assigned to multiple tiers. + Returns: + List of warning messages for emails in multiple tiers. + """ + warnings = [] + all_tier_emails = ( + set(self.admin_emails) | set(self.full_users) | set(self.limited_users) + ) + + tier_map = { + "admin": set(self.admin_emails), + "full": set(self.full_users), + "limited": set(self.limited_users), + } + + for email in all_tier_emails: + tiers = [name for name, emails in tier_map.items() if email in emails] if len(tiers) > 1: - warnings.append(f"Email {email} is assigned to multiple tiers: {', '.join(tiers)}") + warnings.append( + f"Email {email} is assigned to multiple tiers: {', '.join(tiers)}" + ) + return warnings + + def _check_public_domains(self) -> list[str]: + """Check for potentially insecure public email domains. - # Check for potential security issues - if "@gmail.com" in self.domain_patterns or "@outlook.com" in self.domain_patterns: - warnings.append("Public email domains (@gmail.com, @outlook.com) in whitelist may be insecure") + Returns: + List of warning messages if public domains are in whitelist. + """ + public_domains = {"@gmail.com", "@outlook.com"} + if self.domain_patterns & public_domains: + return [ + "Public email domains (@gmail.com, @outlook.com) in whitelist may be insecure" + ] + return [] + + def validate_whitelist_config(self) -> list[str]: + """Validate the whitelist configuration and return any warnings. + Returns: + List of warning messages about the configuration + """ + warnings = [] + warnings.extend(self._check_empty_whitelist()) + warnings.extend(self._check_tier_authorization(self.admin_emails, "Admin")) + warnings.extend(self._check_tier_authorization(self.full_users, "Full user")) + warnings.extend( + self._check_tier_authorization(self.limited_users, "Limited user") + ) + warnings.extend(self._check_tier_conflicts()) + warnings.extend(self._check_public_domains()) return warnings @@ -481,6 +525,111 @@ def __init__(self, validator: EmailWhitelistValidator) -> None: """ self.validator = validator + def _validate_empty_input(self, email: str) -> None: + """Validate that email input is not empty. + + Args: + email: Email string to validate + + Raises: + ValueError: If email is empty or whitespace only + """ + if not email or not email.strip(): + msg = "Email cannot be empty" + raise ValueError(msg) + + def _validate_email_with_library(self, email: str) -> str: + """Validate email using email-validator library. + + Args: + email: Email to validate + + Returns: + Normalized email address + + Raises: + ValueError: If email format is invalid + """ + try: + valid = validate_email(email, check_deliverability=False) + return valid.normalized if not self.validator.case_sensitive else email + except EmailNotValidError as e: + msg = f"Invalid email format: {e!s}" + raise ValueError(msg) from e + + def _validate_email_basic(self, email: str) -> None: + """Basic email validation without email-validator library. + + Args: + email: Email to validate + + Raises: + ValueError: If email format is invalid + """ + if "@" not in email or email.count("@") != 1: + msg = "Invalid email format: must contain exactly one @" + raise ValueError(msg) + + local, domain = email.split("@") + if not local or not domain or "." not in domain: + msg = "Invalid email format" + raise ValueError(msg) + + def _validate_email_format(self, email: str) -> str: + """Validate individual email format. + + Delegates to library validation if available, otherwise uses basic validation. + May raise ValueError from helper methods if email format is invalid. + + Args: + email: Email address to validate + + Returns: + Normalized email address + """ + if EMAIL_VALIDATOR_AVAILABLE and validate_email is not None: + return self._validate_email_with_library(email) + self._validate_email_basic(email) + return email + + def _validate_domain_pattern(self, pattern: str) -> None: + """Validate domain pattern format. + + Args: + pattern: Domain pattern to validate (e.g., @domain.tld) + + Raises: + ValueError: If domain pattern is invalid + """ + if pattern.count("@") != 1: + msg = "Invalid domain pattern: must be @domain.tld" + raise ValueError(msg) + + domain_part = pattern[1:] # Remove @ + if not domain_part or "." not in domain_part: + msg = "Invalid domain pattern: must include valid domain" + raise ValueError(msg) + + def _add_to_collections( + self, normalized_email: str, is_admin: bool, original_email: str + ) -> None: + """Add email to appropriate whitelist collections. + + Args: + normalized_email: Normalized email or domain pattern + is_admin: Whether to add to admin list + original_email: Original email for logging + """ + if normalized_email.startswith("@"): + self.validator.domain_patterns.add(normalized_email) + else: + self.validator.individual_emails.add(normalized_email) + + if is_admin: + self.validator.admin_emails.append(normalized_email) + + logger.info("Added email %s to whitelist (admin: %s)", original_email, is_admin) + def add_email(self, email: str, is_admin: bool = False) -> bool: """Add email to whitelist (runtime operation). @@ -498,58 +647,23 @@ def add_email(self, email: str, is_admin: bool = False) -> bool: ValueError: If email format is invalid """ try: - # Validate input is not empty - if not email or not email.strip(): - raise ValueError("Email cannot be empty") - + self._validate_empty_input(email) normalized_email = self.validator._normalize_email(email) - # Validate email format (if not a domain pattern) - if not normalized_email.startswith("@"): - if EMAIL_VALIDATOR_AVAILABLE and validate_email is not None: - try: - # Validate email format - valid = validate_email(normalized_email, check_deliverability=False) - normalized_email = valid.normalized if not self.validator.case_sensitive else normalized_email - except EmailNotValidError as e: - raise ValueError(f"Invalid email format: {str(e)}") from e - else: - # Basic email validation if email-validator not available - if "@" not in normalized_email or normalized_email.count("@") != 1: - raise ValueError("Invalid email format: must contain exactly one @") - - local, domain = normalized_email.split("@") - if not local or not domain or "." not in domain: - raise ValueError("Invalid email format") - - # Validate domain pattern format - else: - # Domain pattern must be @domain.tld format - if normalized_email.count("@") != 1: - raise ValueError("Invalid domain pattern: must be @domain.tld") - - domain_part = normalized_email[1:] # Remove @ - if not domain_part or "." not in domain_part: - raise ValueError("Invalid domain pattern: must include valid domain") - - # Add to appropriate collection if normalized_email.startswith("@"): - self.validator.domain_patterns.add(normalized_email) + self._validate_domain_pattern(normalized_email) else: - self.validator.individual_emails.add(normalized_email) - - if is_admin: - self.validator.admin_emails.append(normalized_email) + normalized_email = self._validate_email_format(normalized_email) - logger.info("Added email %s to whitelist (admin: %s)", email, is_admin) + self._add_to_collections(normalized_email, is_admin, email) return True except ValueError: - # Re-raise validation errors raise except Exception as e: - logger.error("Failed to add email %s to whitelist: %s", email, e) - raise ValueError(f"Failed to add email: {str(e)}") from e + logger.exception("Failed to add email %s to whitelist: %s", email, e) + msg = f"Failed to add email: {e!s}" + raise ValueError(msg) from e def remove_email(self, email: str) -> bool: """Remove email from whitelist (runtime operation). @@ -585,7 +699,7 @@ def remove_email(self, email: str) -> bool: return removed except Exception as e: - logger.error("Failed to remove email %s from whitelist: %s", email, e) + logger.exception("Failed to remove email %s from whitelist: %s", email, e) return False def check_email(self, email: str) -> dict[str, Any]: diff --git a/packages/cloudflare-auth/tests/test_models.py b/packages/cloudflare-auth/tests/test_models.py index 8dc97cf..4a24cc5 100644 --- a/packages/cloudflare-auth/tests/test_models.py +++ b/packages/cloudflare-auth/tests/test_models.py @@ -1,12 +1,10 @@ """Tests for cloudflare_auth models.""" -import pytest - class TestModels: """Test suite for cloudflare_auth models.""" - def test_placeholder(self): + def test_placeholder(self) -> None: """Placeholder test - replace with actual tests.""" # TODO: Add actual model tests once dependencies are resolved assert True diff --git a/packages/gcs-utilities/src/gcs_utilities/__init__.py b/packages/gcs-utilities/src/gcs_utilities/__init__.py index 2db2a2b..aeb7692 100644 --- a/packages/gcs-utilities/src/gcs_utilities/__init__.py +++ b/packages/gcs-utilities/src/gcs_utilities/__init__.py @@ -4,4 +4,10 @@ from .exceptions import GCSAuthError, GCSDownloadError, GCSError, GCSUploadError __version__ = "0.1.0" -__all__ = ["GCSClient", "GCSError", "GCSAuthError", "GCSUploadError", "GCSDownloadError"] +__all__ = [ + "GCSAuthError", + "GCSClient", + "GCSDownloadError", + "GCSError", + "GCSUploadError", +] diff --git a/packages/gcs-utilities/src/gcs_utilities/client.py b/packages/gcs-utilities/src/gcs_utilities/client.py index f7ea4b7..34e062e 100644 --- a/packages/gcs-utilities/src/gcs_utilities/client.py +++ b/packages/gcs-utilities/src/gcs_utilities/client.py @@ -62,7 +62,7 @@ def __init__( bucket_name: str | None = None, project_id: str | None = None, auto_create_bucket: bool = False, - ): + ) -> None: """Initialize GCS client. Args: @@ -88,7 +88,8 @@ def __init__( try: self.client = storage.Client(project=self.project_id) except (GoogleCloudError, ValueError, OSError) as e: - raise GCSAuthError(f"Failed to initialize GCS client: {e}") from e + msg = f"Failed to initialize GCS client: {e}" + raise GCSAuthError(msg) from e # Get or create bucket if specified if self.bucket_name: @@ -119,10 +120,11 @@ def _setup_credentials(self, service_account_key_b64: str | None = None) -> None b64_key = service_account_key_b64 or os.getenv("GCP_SA_KEY") if not b64_key: - raise GCSConfigError( + msg = ( "No service account credentials provided. " "Set GCP_SA_KEY environment variable or pass service_account_key_b64 parameter." ) + raise GCSConfigError(msg) try: # Decode base64 to get JSON content @@ -134,7 +136,9 @@ def _setup_credentials(self, service_account_key_b64: str | None = None) -> None self.project_id = sa_data.get("project_id") or os.getenv("GCP_PROJECT") # Write to temporary file with secure permissions - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: + with tempfile.NamedTemporaryFile( + mode="w", delete=False, suffix=".json" + ) as f: f.write(json_content) self._credentials_path = f.name @@ -152,9 +156,11 @@ def _setup_credentials(self, service_account_key_b64: str | None = None) -> None logger.info(f"GCS credentials configured for project: {self.project_id}") except (base64.binascii.Error, json.JSONDecodeError) as e: - raise GCSAuthError(f"Invalid service account key format: {e}") from e + msg = f"Invalid service account key format: {e}" + raise GCSAuthError(msg) from e except (OSError, ValueError) as e: - raise GCSAuthError(f"Failed to setup credentials: {e}") from e + msg = f"Failed to setup credentials: {e}" + raise GCSAuthError(msg) from e def _get_or_create_bucket(self, auto_create: bool = False) -> storage.Bucket: """Get bucket or optionally create it if it doesn't exist. @@ -176,15 +182,17 @@ def _get_or_create_bucket(self, auto_create: bool = False) -> storage.Bucket: logger.info(f"Creating bucket: {self.bucket_name}") bucket = self.client.create_bucket(self.bucket_name) else: - raise GCSNotFoundError( + msg = ( f"Bucket '{self.bucket_name}' does not exist. " "Set auto_create_bucket=True to create it automatically." ) + raise GCSNotFoundError(msg) return bucket except Exception as e: if isinstance(e, GCSNotFoundError): raise - raise GCSAuthError(f"Failed to access bucket '{self.bucket_name}': {e}") from e + msg = f"Failed to access bucket '{self.bucket_name}': {e}" + raise GCSAuthError(msg) from e def set_bucket(self, bucket_name: str, auto_create: bool = False) -> None: """Set or change the default bucket. @@ -217,12 +225,14 @@ def _validate_local_path(path: Path, must_exist: bool = False) -> Path: # Check if path exists when required if must_exist and not resolved_path.exists(): - raise FileNotFoundError(f"Path does not exist: {path}") + msg = f"Path does not exist: {path}" + raise FileNotFoundError(msg) return resolved_path except (OSError, RuntimeError) as e: - raise ValueError(f"Invalid path: {path} - {e}") from e + msg = f"Invalid path: {path} - {e}" + raise ValueError(msg) from e @staticmethod def _sanitize_gcs_path(gcs_path: str) -> str: @@ -242,11 +252,13 @@ def _sanitize_gcs_path(gcs_path: str) -> str: # Check for empty path if not gcs_path or gcs_path.isspace(): - raise ValueError("GCS path cannot be empty") + msg = "GCS path cannot be empty" + raise ValueError(msg) # Check for suspicious patterns if ".." in gcs_path: - raise ValueError("GCS path cannot contain '..' segments") + msg = "GCS path cannot contain '..' segments" + raise ValueError(msg) return gcs_path @@ -292,12 +304,15 @@ def upload_file( full_uri = f"gs://{bucket.name}/{gcs_path}" file_size_mb = local_file.stat().st_size / BYTES_PER_MB - logger.info(f"✅ Uploaded {local_path} ({file_size_mb:.2f} MB) → {full_uri}") + logger.info( + f"✅ Uploaded {local_path} ({file_size_mb:.2f} MB) → {full_uri}" + ) return full_uri except GoogleCloudError as e: - raise GCSUploadError(f"Failed to upload {local_path} to {gcs_path}: {e}") from e + msg = f"Failed to upload {local_path} to {gcs_path}: {e}" + raise GCSUploadError(msg) from e def upload_directory( self, @@ -357,10 +372,10 @@ def upload_directory( stats["total_bytes"] += file_size size_mb = file_size / BYTES_PER_MB - logger.info(f"✅ {str(rel_path):<50} ({size_mb:>6.2f} MB) → {gcs_path}") + logger.info(f"✅ {rel_path!s:<50} ({size_mb:>6.2f} MB) → {gcs_path}") except GoogleCloudError as e: - logger.error(f"❌ Failed to upload {rel_path}: {e}") + logger.exception(f"❌ Failed to upload {rel_path}: {e}") stats["failed"].append(str(rel_path)) total_mb = stats["total_bytes"] / BYTES_PER_MB @@ -405,7 +420,8 @@ def download_file( # Check if blob exists if not blob.exists(): - raise GCSNotFoundError(f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}") + msg = f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}" + raise GCSNotFoundError(msg) # Create parent directories if needed if create_dirs: @@ -415,14 +431,15 @@ def download_file( blob.download_to_filename(str(local_file)) file_size_mb = local_file.stat().st_size / BYTES_PER_MB - logger.info(f"✅ Downloaded gs://{bucket.name}/{gcs_path} ({file_size_mb:.2f} MB)") + logger.info( + f"✅ Downloaded gs://{bucket.name}/{gcs_path} ({file_size_mb:.2f} MB)" + ) return str(local_file) except GoogleCloudError as e: - raise GCSDownloadError( - f"Failed to download gs://{bucket.name}/{gcs_path}: {e}" - ) from e + msg = f"Failed to download gs://{bucket.name}/{gcs_path}: {e}" + raise GCSDownloadError(msg) from e def download_as_bytes( self, @@ -447,14 +464,14 @@ def download_as_bytes( blob = bucket.blob(gcs_path) if not blob.exists(): - raise GCSNotFoundError(f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}") + msg = f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}" + raise GCSNotFoundError(msg) try: return blob.download_as_bytes() except GoogleCloudError as e: - raise GCSDownloadError( - f"Failed to download gs://{bucket.name}/{gcs_path}: {e}" - ) from e + msg = f"Failed to download gs://{bucket.name}/{gcs_path}: {e}" + raise GCSDownloadError(msg) from e def download_as_text( self, @@ -481,14 +498,14 @@ def download_as_text( blob = bucket.blob(gcs_path) if not blob.exists(): - raise GCSNotFoundError(f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}") + msg = f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}" + raise GCSNotFoundError(msg) try: return blob.download_as_text(encoding=encoding) except (GoogleCloudError, UnicodeDecodeError) as e: - raise GCSDownloadError( - f"Failed to download gs://{bucket.name}/{gcs_path}: {e}" - ) from e + msg = f"Failed to download gs://{bucket.name}/{gcs_path}: {e}" + raise GCSDownloadError(msg) from e def list_files( self, @@ -522,18 +539,21 @@ def list_files( files = [] for blob in blobs: - files.append({ - "name": blob.name, - "size": blob.size, - "updated": blob.updated, - "content_type": blob.content_type, - "uri": f"gs://{bucket.name}/{blob.name}", - }) + files.append( + { + "name": blob.name, + "size": blob.size, + "updated": blob.updated, + "content_type": blob.content_type, + "uri": f"gs://{bucket.name}/{blob.name}", + } + ) return files except GoogleCloudError as e: - raise GCSDownloadError(f"Failed to list files: {e}") from e + msg = f"Failed to list files: {e}" + raise GCSDownloadError(msg) from e def delete_file( self, @@ -567,12 +587,11 @@ def delete_file( if ignore_missing: logger.debug(f"File not found (ignored): gs://{bucket.name}/{gcs_path}") return False - else: - raise GCSNotFoundError( - f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}" - ) from None + msg = f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}" + raise GCSNotFoundError(msg) from None except GoogleCloudError as e: - raise GCSDownloadError(f"Failed to delete gs://{bucket.name}/{gcs_path}: {e}") from e + msg = f"Failed to delete gs://{bucket.name}/{gcs_path}: {e}" + raise GCSDownloadError(msg) from e def delete_directory( self, @@ -600,7 +619,7 @@ def delete_directory( count += 1 logger.debug(f"🗑️ Deleted {blob.name}") except GoogleCloudError as e: - logger.error(f"Failed to delete {blob.name}: {e}") + logger.exception(f"Failed to delete {blob.name}: {e}") logger.info(f"🗑️ Deleted {count} files with prefix '{prefix}'") return count @@ -646,7 +665,8 @@ def get_file_metadata( blob = bucket.blob(gcs_path) if not blob.exists(): - raise GCSNotFoundError(f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}") + msg = f"File does not exist in GCS: gs://{bucket.name}/{gcs_path}" + raise GCSNotFoundError(msg) # Reload to get latest metadata blob.reload() @@ -676,13 +696,13 @@ def _get_bucket(self, bucket_name: str | None = None) -> storage.Bucket: """ if bucket_name: return self.client.bucket(bucket_name) - elif self.bucket: + if self.bucket: return self.bucket - else: - raise GCSConfigError( - "No bucket specified. Either set a default bucket with set_bucket() " - "or provide bucket_name parameter." - ) + msg = ( + "No bucket specified. Either set a default bucket with set_bucket() " + "or provide bucket_name parameter." + ) + raise GCSConfigError(msg) def _cleanup_credentials(self) -> None: """Cleanup temporary credentials file.""" diff --git a/packages/gcs-utilities/src/gcs_utilities/exceptions.py b/packages/gcs-utilities/src/gcs_utilities/exceptions.py index 8a4500a..21b0c9c 100644 --- a/packages/gcs-utilities/src/gcs_utilities/exceptions.py +++ b/packages/gcs-utilities/src/gcs_utilities/exceptions.py @@ -4,34 +4,22 @@ class GCSError(Exception): """Base exception for GCS utilities.""" - pass - class GCSAuthError(GCSError): """Raised when authentication to GCS fails.""" - pass - class GCSUploadError(GCSError): """Raised when file upload to GCS fails.""" - pass - class GCSDownloadError(GCSError): """Raised when file download from GCS fails.""" - pass - class GCSNotFoundError(GCSError): """Raised when a requested GCS object is not found.""" - pass - class GCSConfigError(GCSError): """Raised when GCS configuration is invalid or missing.""" - - pass diff --git a/packages/gcs-utilities/tests/conftest.py b/packages/gcs-utilities/tests/conftest.py index 6610722..ea694e7 100644 --- a/packages/gcs-utilities/tests/conftest.py +++ b/packages/gcs-utilities/tests/conftest.py @@ -4,12 +4,12 @@ @pytest.fixture -def mock_bucket_name(): +def mock_bucket_name() -> str: """Sample bucket name for testing.""" return "test-bucket" @pytest.fixture -def mock_blob_name(): +def mock_blob_name() -> str: """Sample blob name for testing.""" return "test/path/file.txt" diff --git a/packages/gcs-utilities/tests/test_exceptions.py b/packages/gcs-utilities/tests/test_exceptions.py index 4e3675d..bf01ab4 100644 --- a/packages/gcs-utilities/tests/test_exceptions.py +++ b/packages/gcs-utilities/tests/test_exceptions.py @@ -1,12 +1,10 @@ """Tests for gcs_utilities exceptions.""" -import pytest - class TestExceptions: """Test suite for gcs_utilities exceptions.""" - def test_placeholder(self): + def test_placeholder(self) -> None: """Placeholder test - replace with actual tests.""" # TODO: Add actual exception tests once dependencies are resolved assert True diff --git a/pyproject.toml b/pyproject.toml index 2c252af..f39e418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ classifiers = [ # All production deps are in individual packages (packages/*/pyproject.toml) dependencies = [ # Shared utilities only - packages define their own deps + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", "structlog>=23.1.0", "rich>=13.5.0", # Cross-Version Compatibility Dependencies @@ -386,6 +388,68 @@ known-first-party = ["python_libs", "cloudflare_auth", "gcs_utilities"] "S310", # URL schemes are validated before use (see nosec comment) ] +# Cloudflare auth middleware - Complex authentication logic +"packages/cloudflare-auth/src/cloudflare_auth/middleware*.py" = [ + "TRY300", # Return in try block is clearer for auth flow + "TRY301", # Raising in try block is standard for auth middleware + "PLC0415", # Local imports to avoid circular dependencies +] + +# Cloudflare auth whitelist - Validation logic +"packages/cloudflare-auth/src/cloudflare_auth/whitelist.py" = [ + "TRY300", # Return in try block is clearer + "TRY301", # Raising in try block is standard + "TRY401", # Exception in log is intentional for audit + "SLF001", # Internal access to validator internals is intentional + "PGH003", # Generic type ignore is acceptable for optional import + "PERF401", # List comprehension vs append is clearer here +] + +# Cloudflare auth redis sessions - Session management logic +"packages/cloudflare-auth/src/cloudflare_auth/redis_sessions.py" = [ + "TRY300", # Return in try block is clearer for session flow + "TRY401", # Exception in log message is intentional +] + +# Cloudflare auth security helpers - Security patterns +"packages/cloudflare-auth/src/cloudflare_auth/security_helpers.py" = [ + "G201", # Using .error with exc_info is intentional for security logging + "TRY300", # Return patterns in try blocks + "SLF001", # Function attribute pattern for singleton +] + +# Cloudflare auth validators - JWT validation logic +"packages/cloudflare-auth/src/cloudflare_auth/validators.py" = [ + "TRY300", # Return in try block is clearer for validation + "TRY301", # Raising in try block is standard for validation + "TRY401", # Exception in log is intentional +] + +# Cloudflare auth utils - Utility functions +"packages/cloudflare-auth/src/cloudflare_auth/utils.py" = [ + "BLE001", # Blind except is intentional for resilient parsing + "ANN001", # Regex match type annotation not needed + "ANN202", # Private function return type annotation not needed +] + +# Cloudflare auth tests - Test patterns +"packages/cloudflare-auth/tests/*.py" = [ + "ANN201", # Return type annotations not needed for fixtures + "PERF401", # List comprehension vs extend is fine in tests +] + +# GCS utilities client - Storage operations +"packages/gcs-utilities/src/gcs_utilities/client.py" = [ + "G004", # F-string logging is cleaner for GCS operations + "TRY300", # Return in try block is clearer for storage ops + "TRY301", # Raising in try block is standard for storage errors + "TRY401", # Exception in log is intentional + "PTH101", # os.chmod preferred for credential file permissions + "PTH108", # os.unlink preferred for credential file cleanup + "PTH110", # os.path.exists preferred for credential file check + "PERF401", # List comprehension vs append is clearer here +] + # BasedPyright Configuration # Replaces MyPy with faster, stricter type checking # Reference: https://docs.basedpyright.com @@ -652,6 +716,16 @@ byronwilliamscpa-gcs-utilities = { workspace = true } # UV automatically falls back to PyPI when packages are not found in Assured OSS. # Authentication uses GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_APPLICATION_CREDENTIALS_B64. +# Artifact Registry Publishing Configuration +# Used by `uv publish` to upload packages to private GCP Artifact Registry +# The publish URL is passed via CLI in the GitHub Actions workflow +# Format: https://{REGION}-python.pkg.dev/{PROJECT}/{REPOSITORY}/ +# +# For local publishing, set environment variables and run: +# uv publish --publish-url https://us-central1-python.pkg.dev/assured-oss-457903/python-libs/ +# +# Authentication is handled via gcloud credential helper or service account key. + # Semantic Release Configuration # Automates versioning and changelog generation based on conventional commits # Reference: https://python-semantic-release.readthedocs.io/ diff --git a/scripts/README.md b/scripts/README.md index b412569..1132583 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -9,16 +9,19 @@ Utility scripts for Python Libs. Updates the Claude Code standards from the upstream repository. **Usage**: + ```bash ./scripts/update-claude-standards.sh ``` **What it does**: + - Pulls the latest Claude Code standards from [williaby/.claude](https://github.com/williaby/.claude) - Updates the `.claude/standard/` directory via git subtree - Preserves project-specific configuration in `.claude/claude.md` **When to run**: + - Periodically to get latest standards and best practices - When new Claude Code features are announced - When security or quality updates are released diff --git a/scripts/check_fips_compatibility.py b/scripts/check_fips_compatibility.py old mode 100644 new mode 100755 diff --git a/scripts/check_orphaned_files.py b/scripts/check_orphaned_files.py old mode 100644 new mode 100755 diff --git a/scripts/check_type_hints.py b/scripts/check_type_hints.py old mode 100644 new mode 100755 diff --git a/scripts/cleanup_conditional_files.py b/scripts/cleanup_conditional_files.py old mode 100644 new mode 100755 diff --git a/scripts/cruft-update.sh b/scripts/cruft-update.sh old mode 100644 new mode 100755 diff --git a/scripts/setup_github_protection.py b/scripts/setup_github_protection.py old mode 100644 new mode 100755 diff --git a/scripts/validate_assuredoss.py b/scripts/validate_assuredoss.py old mode 100644 new mode 100755 diff --git a/tests/test_example.py b/tests/test_example.py index 4f1961a..6f18722 100644 --- a/tests/test_example.py +++ b/tests/test_example.py @@ -146,6 +146,7 @@ def test_log_performance(self) -> None: assert call_args[1]["extra_metric"] == 42 +@pytest.mark.skip(reason="CLI module not implemented yet - placeholder tests from template") class TestCLI: """Test command-line interface. diff --git a/tools/validate_front_matter.py b/tools/validate_front_matter.py old mode 100644 new mode 100755 diff --git a/uv.lock b/uv.lock index c6951ec..4185a45 100644 --- a/uv.lock +++ b/uv.lock @@ -326,6 +326,7 @@ dependencies = [ { name = "cryptography" }, { name = "httpx" }, { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pyjwt" }, ] @@ -355,6 +356,7 @@ requires-dist = [ { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.100.0" }, { name = "httpx", specifier = ">=0.25.0" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pyjwt", specifier = ">=2.8.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, @@ -2785,6 +2787,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2931,6 +2947,8 @@ name = "python-libs" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "rich" }, { name = "structlog" }, ] @@ -2993,6 +3011,8 @@ requires-dist = [ { name = "nox-uv", marker = "extra == 'dev'", specifier = ">=0.6.3" }, { name = "pip-audit", marker = "extra == 'dev'", specifier = ">=2.7.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.3.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },