├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── changed-files-spec.yml ├── renovate.json5 └── workflows │ ├── ci.yml │ ├── deploy-docs.yml │ ├── release.yml │ ├── validate.yml │ └── verify_upstream.sh ├── .gitignore ├── .gitlint ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Taskfile.yml ├── config └── release-templates │ ├── .components │ ├── changelog_header.md.j2 │ ├── changelog_init.md.j2 │ ├── changelog_update.md.j2 │ ├── changes.md.j2 │ ├── first_release.md.j2 │ ├── macros.common.j2 │ ├── macros.md.j2 │ ├── unreleased_changes.md.j2 │ └── versioned_changes.md.j2 │ ├── .release_notes.md.j2 │ └── CHANGELOG.md.j2 ├── docs ├── __init__.py ├── changelog.md ├── code │ ├── __init__.py │ ├── getting_started │ │ ├── __init__.py │ │ ├── application.py │ │ ├── greetings │ │ │ ├── __init__.py │ │ │ ├── models.py │ │ │ ├── module.py │ │ │ └── services.py │ │ ├── main.py │ │ ├── settings.py │ │ └── users │ │ │ ├── __init__.py │ │ │ ├── models.py │ │ │ ├── module.py │ │ │ └── services.py │ └── providers │ │ ├── __init__.py │ │ ├── manual_di.py │ │ └── scopes │ │ ├── __init__.py │ │ ├── contextual.py │ │ ├── contextual_real.py │ │ ├── object.py │ │ ├── scoped.py │ │ ├── singleton.py │ │ └── transient.py ├── contributing │ ├── docs.md │ └── index.md ├── examples │ ├── cqrs.md │ └── modularity.md ├── getting-started.md ├── includes │ └── abbreviations.md ├── index.md ├── integrations │ ├── asgi.md │ ├── index.md │ └── litestar.md ├── reference.md └── usage │ ├── cqrs.md │ ├── extensions │ ├── index.md │ └── validation.md │ ├── lifespan.md │ ├── modules.md │ └── providers.md ├── examples ├── __init__.py ├── contextual_provider.py ├── cqrs │ ├── __init__.py │ ├── basic_usage.py │ └── pipeline_behaviors.py └── modularity.py ├── gitlint_plugins.py ├── mkdocs.yml ├── pyproject.toml ├── pyrightconfig.json ├── src └── waku │ ├── __init__.py │ ├── application.py │ ├── cqrs │ ├── __init__.py │ ├── contracts │ │ ├── __init__.py │ │ ├── event.py │ │ ├── pipeline.py │ │ └── request.py │ ├── events │ │ ├── __init__.py │ │ ├── handler.py │ │ ├── map.py │ │ └── publish.py │ ├── exceptions.py │ ├── impl.py │ ├── interfaces.py │ ├── modules.py │ ├── pipeline │ │ ├── __init__.py │ │ ├── chain.py │ │ └── map.py │ ├── requests │ │ ├── __init__.py │ │ ├── handler.py │ │ └── map.py │ └── utils.py │ ├── di.py │ ├── exceptions.py │ ├── extensions │ ├── __init__.py │ ├── protocols.py │ └── registry.py │ ├── factory.py │ ├── lifespan.py │ ├── modules │ ├── __init__.py │ ├── _metadata.py │ ├── _module.py │ ├── _registry.py │ └── _registry_builder.py │ ├── py.typed │ ├── testing.py │ └── validation │ ├── __init__.py │ ├── _abc.py │ ├── _errors.py │ ├── _extension.py │ └── rules │ ├── __init__.py │ ├── _cache.py │ ├── _types_extractor.py │ └── dependency_accessible.py ├── tests ├── __init__.py ├── application │ ├── __init__.py │ ├── test_lifecycle.py │ └── test_module_registration.py ├── conftest.py ├── data.py ├── di │ ├── __init__.py │ ├── test_providers.py │ └── test_scopes.py ├── extensions │ ├── __init__.py │ ├── test_application_extensions.py │ ├── test_module_extensions.py │ └── test_registry.py ├── module_utils.py ├── modules │ ├── __init__.py │ └── test_dynamic_modules.py ├── test_testing_override.py └── validation │ ├── __init__.py │ └── test_dependencies_accessible.py └── uv.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Check http://editorconfig.org for more information 2 | # This is the main config file for this project: 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [*.{py,pyi}] 14 | indent_size = 4 15 | max_line_length = 120 16 | 17 | [*.{diff,patch,md}] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # https://help.github.com/articles/dealing-with-line-endings/ 2 | * text=auto eol=lf 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug in waku 4 | title: 'bug: ' 5 | labels: [ 'bug' ] 6 | assignees: '' 7 | --- 8 | 9 | ## Bug Description 10 | 11 | 12 | 13 | ## Reproduction Steps 14 | 15 | 16 | 17 | ```python 18 | # Minimal code example that demonstrates the issue 19 | def reproduce_bug(): 20 | # Your reproduction code here 21 | ... 22 | 23 | ``` 24 | 25 | ## Expected Behavior 26 | 27 | 28 | 29 | ## Actual Behavior 30 | 31 | 32 | 33 | Error output/stack trace here 34 | 35 | ## Environment Information 36 | 37 | 38 | 39 | ```yaml 40 | waku version: x.x.x 41 | python version: 3.x.x 42 | os: [ Linux/MacOS/Windows ] 43 | dependencies: 44 | - package: version 45 | ``` 46 | 47 | ## Configuration 48 | 49 | Application/Module setup, or any other relevant configs 50 | 51 | # Validation Steps 52 | 53 | 54 | 55 | - [ ] I have the latest version 56 | - [ ] I have checked existing issues for duplicates 57 | - [ ] I have included all relevant logs/traces 58 | - [ ] I can reliably reproduce this issue 59 | 60 | ## Additional Context 61 | 62 | 63 | 64 | - Related issues: # 65 | - Affected components: 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea or enhancement for waku 4 | title: 'feat: ' 5 | labels: 'enhancement' 6 | assignees: '' 7 | --- 8 | 9 | ## Problem Statement 10 | 11 | 15 | 16 | ## Proposed Solution 17 | 18 | 24 | 25 | ### API Design (if applicable) 26 | 27 | ```python 28 | # Example usage of your proposed feature 29 | 30 | ``` 31 | 32 | ### Configuration (if applicable) 33 | 34 | ```yaml 35 | # Example configuration if needed 36 | ``` 37 | 38 | ## Alternative Solutions 39 | 43 | 44 | ## Implementation Considerations 45 | 46 | 53 | 54 | ## Additional Context 55 | 56 | 62 | 63 | ## Checklist 64 | 65 | 66 | 67 | - [ ] I have searched existing issues for duplicates 68 | - [ ] This feature aligns with `waku` modular architecture 69 | - [ ] This feature maintains loose coupling principles 70 | - [ ] I'm willing to help implement this feature 71 | 72 | ## Related Links 73 | 74 | 75 | 76 | - Related issues: # 77 | - Documentation: 78 | - External references: 79 | -------------------------------------------------------------------------------- /.github/changed-files-spec.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | build: 4 | - 'pyproject.toml' 5 | - 'uv.lock' 6 | ci: 7 | - '.github/changed-files-spec.yml' 8 | - '.github/workflows/**' 9 | - '.pre-commit-config.yaml' 10 | - 'Taskfile.yml' 11 | - 'gitlint_plugins.py' 12 | docs: 13 | - 'docs/**' 14 | - '**/*.md' 15 | - 'mkdocs.yml' 16 | src: 17 | - 'src/**' 18 | tests: 19 | - 'tests/**' 20 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:best-practices", 5 | "schedule:daily", 6 | ":enablePreCommit", 7 | ":maintainLockFilesWeekly", 8 | ":prHourlyLimitNone", 9 | ":semanticCommits", 10 | ":automergeMinor" 11 | ], 12 | "labels": [ 13 | "dependencies", 14 | "renovate" 15 | ], 16 | "packageRules": [ 17 | { 18 | "matchUpdateTypes": [ 19 | "minor", 20 | "patch" 21 | ], 22 | "matchCurrentVersion": "!/^0/", 23 | "automerge": true 24 | } 25 | ], 26 | "prConcurrentLimit": 0, 27 | "assignees": [ 28 | "fadedDexofan" 29 | ], 30 | "reviewers": [ 31 | "fadedDexofan" 32 | ], 33 | "timezone": "UTC", 34 | "rangeStrategy": "auto", 35 | "lockFileMaintenance": { 36 | "enabled": true, 37 | "automerge": true, 38 | "automergeType": "pr", 39 | "schedule": [ 40 | "before 5am on monday" 41 | ] 42 | }, 43 | "vulnerabilityAlerts": { 44 | "labels": [ 45 | "security" 46 | ], 47 | "assignees": [ 48 | "fadedDexofan" 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | pull_request: 6 | types: [ opened, synchronize, reopened, ready_for_review ] 7 | branches: # Target branches 8 | - master 9 | 10 | # default token permissions = none 11 | permissions: { } 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | eval-changes: 19 | name: Evaluate changes 20 | # condition: Execute IFF it is protected branch update, or a PR that is NOT in a draft state 21 | if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }} 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 26 | with: 27 | fetch-depth: 100 28 | 29 | - name: Evaluate | Check common file types for changes 30 | id: core-changed-files 31 | uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 32 | with: 33 | files_yaml_from_source_file: .github/changed-files-spec.yml 34 | 35 | - name: Evaluate | Detect if any of the combinations of file sets have changed 36 | id: all-changes 37 | run: | 38 | printf '%s\n' "any_changed=false" >> "$GITHUB_OUTPUT" 39 | if [ "${{ steps.core-changed-files.outputs.build_any_changed }}" == "true" ] || \ 40 | [ "${{ steps.core-changed-files.outputs.ci_any_changed }}" == "true" ] || \ 41 | [ "${{ steps.core-changed-files.outputs.docs_any_changed }}" == "true" ] || \ 42 | [ "${{ steps.core-changed-files.outputs.src_any_changed }}" == "true" ] || \ 43 | [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ]; then 44 | printf '%s\n' "any_changed=true" >> "$GITHUB_OUTPUT" 45 | fi 46 | 47 | outputs: 48 | # essentially casts the string output to a boolean for GitHub 49 | any-file-changes: ${{ steps.all-changes.outputs.any_changed }} 50 | build-changes: ${{ steps.core-changed-files.outputs.build_any_changed }} 51 | ci-changes: ${{ steps.core-changed-files.outputs.ci_any_changed }} 52 | doc-changes: ${{ steps.core-changed-files.outputs.docs_any_changed }} 53 | src-changes: ${{ steps.core-changed-files.outputs.src_any_changed }} 54 | test-changes: ${{ steps.core-changed-files.outputs.tests_any_changed }} 55 | 56 | validate: 57 | uses: ./.github/workflows/validate.yml 58 | needs: eval-changes 59 | with: 60 | python-versions: '[ "3.11", "3.12", "3.13"]' 61 | files-changed: ${{ needs.eval-changes.outputs.any-file-changes }} 62 | build-files-changed: ${{ needs.eval-changes.outputs.build-changes }} 63 | ci-files-changed: ${{ needs.eval-changes.outputs.ci-changes }} 64 | doc-files-changed: ${{ needs.eval-changes.outputs.doc-changes }} 65 | src-files-changed: ${{ needs.eval-changes.outputs.src-changes }} 66 | test-files-changed: ${{ needs.eval-changes.outputs.test-changes }} 67 | secrets: inherit 68 | permissions: { } 69 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Deploy docs 3 | 4 | on: 5 | # Enable workflow as callable from another workflow 6 | workflow_call: 7 | inputs: 8 | doc-files-changed: 9 | description: 'Boolean string result for if documentation files have changed' 10 | type: string 11 | required: false 12 | default: 'false' 13 | src-files-changed: 14 | description: 'Boolean string result for if source files have changed' 15 | type: string 16 | required: false 17 | default: 'false' 18 | 19 | env: 20 | COMMON_PYTHON_VERSION: '3.12' 21 | 22 | jobs: 23 | deploy-docs: 24 | name: Deploy docs 25 | runs-on: ubuntu-latest 26 | if: ${{ inputs.doc-files-changed == 'true' || inputs.src-files-changed == 'true' }} 27 | 28 | env: 29 | DOCS_DEPLOY: true 30 | GITHUB_ACTIONS_AUTHOR_NAME: github-actions 31 | GITHUB_ACTIONS_AUTHOR_EMAIL: actions@users.noreply.github.com 32 | 33 | steps: 34 | - name: Setup | Create access token 35 | uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2 36 | id: app-token 37 | with: 38 | app-id: ${{ vars.VERSION_BUMPER_APPID }} 39 | private-key: ${{ secrets.VERSION_BUMPER_SECRET }} 40 | 41 | - name: Setup | Checkout Repository at workflow sha 42 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 43 | with: 44 | ref: ${{ github.sha }} 45 | fetch-depth: 0 46 | token: ${{ steps.app-token.outputs.token }} 47 | 48 | - name: Setup | Configure Git Credentials 49 | run: | 50 | git config user.name ${{ env.GITHUB_ACTIONS_AUTHOR_NAME }} 51 | git config user.email ${{ env.GITHUB_ACTIONS_AUTHOR_EMAIL }} 52 | 53 | - name: Setup | Install Python ${{ env.COMMON_PYTHON_VERSION }} 54 | uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 55 | with: 56 | python-version: ${{ env.COMMON_PYTHON_VERSION }} 57 | 58 | - name: Setup | Install UV 59 | uses: astral-sh/setup-uv@f0ec1fc3b38f5e7cd731bb6ce540c5af426746bb # v6 60 | with: 61 | enable-cache: true 62 | 63 | - name: Deploy | Deploy docs to GitHub Pages 64 | run: uv run --group=docs mkdocs gh-deploy --force 65 | -------------------------------------------------------------------------------- /.github/workflows/verify_upstream.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu +o pipefail 4 | 5 | # Example output of `git status -sb`: 6 | # ## master...origin/master [behind 1] 7 | # M .github/workflows/verify_upstream.sh 8 | UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | cut -d' ' -f2 | grep -E '\.{3}' | cut -d'.' -f4)" 9 | printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" 10 | 11 | set -o pipefail 12 | 13 | if [ -z "$UPSTREAM_BRANCH_NAME" ]; then 14 | printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" 15 | exit 1 16 | fi 17 | 18 | git fetch "${UPSTREAM_BRANCH_NAME%%/*}" 19 | 20 | if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then 21 | printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" 22 | exit 1 23 | fi 24 | 25 | HEAD_SHA="$(git rev-parse HEAD)" 26 | 27 | if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then 28 | printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" 29 | printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." 30 | exit 1 31 | fi 32 | 33 | printf '%s\n' "Verified upstream branch has not changed, continuing with release..." 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .idea 3 | .vscode 4 | 5 | # Git 6 | .git 7 | 8 | # Environment 9 | .env 10 | .venv/ 11 | venv/ 12 | version.env 13 | .version 14 | .python-version 15 | 16 | # Byte-compiled / optimized / DLL files 17 | **/__pycache__/ 18 | **/*.py[cod] 19 | *$py.class 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | .pypirc 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .coverage 45 | .coverage.* 46 | coverage.xml 47 | *.cover 48 | 49 | # Cache dirs 50 | .cache 51 | .*_cache 52 | .task/ 53 | 54 | # Other 55 | .DS_Store 56 | *.log 57 | 58 | # taskfile 59 | .task 60 | task 61 | 62 | # Documentation 63 | site/ 64 | 65 | # LLM 66 | .continue* 67 | .cursor* 68 | -------------------------------------------------------------------------------- /.gitlint: -------------------------------------------------------------------------------- 1 | [general] 2 | ignore=body-is-missing,body-min-length,body-max-line-length 3 | extra-path=./gitlint_plugins.py 4 | 5 | [title-max-length] 6 | line-length=150 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # Pre-commit configuration for waku 2 | # See https://pre-commit.com for more information 3 | default_install_hook_types: [ pre-commit, pre-push, commit-msg ] 4 | default_stages: [ pre-commit, pre-push, manual ] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v5.0.0 9 | hooks: 10 | - id: no-commit-to-branch 11 | args: [ "--branch", "master" ] 12 | - id: check-yaml 13 | args: [ "--unsafe" ] 14 | files: '\.(yaml|yml)$' 15 | - id: check-toml 16 | files: '\.toml$' 17 | - id: check-merge-conflict 18 | - id: end-of-file-fixer 19 | exclude: '^uv\.lock$' 20 | - id: trailing-whitespace 21 | args: [ "--markdown-linebreak-ext=md" ] 22 | - id: detect-private-key 23 | - id: check-illegal-windows-names 24 | - id: mixed-line-ending 25 | args: [ "--fix=lf" ] 26 | - id: check-case-conflict 27 | 28 | - repo: https://github.com/python-jsonschema/check-jsonschema 29 | rev: 0.33.0 30 | hooks: 31 | - id: check-github-workflows 32 | files: '^\.github/workflows/.*\.ya?ml$' 33 | - id: check-renovate 34 | files: '^\.github/renovate\.json5$' 35 | additional_dependencies: [ pyjson5 ] 36 | - id: check-taskfile 37 | files: '^Taskfile\.yml$' 38 | 39 | - repo: https://github.com/rhysd/actionlint 40 | rev: v1.7.7 41 | hooks: 42 | - id: actionlint 43 | 44 | - repo: https://github.com/codespell-project/codespell 45 | rev: v2.4.1 46 | hooks: 47 | - id: codespell 48 | additional_dependencies: [ tomli ] 49 | files: '\.(py|pyi|md|yml|yaml|json|toml)$' 50 | 51 | - repo: local 52 | hooks: 53 | - id: commit-msg 54 | name: check commit message 55 | stages: [ commit-msg ] 56 | language: system 57 | entry: task gitlint -- --msg-filename 58 | fail_fast: true 59 | 60 | - id: uv-lock-check 61 | name: uv-lock-check 62 | entry: uv lock --locked 63 | language: system 64 | pass_filenames: false 65 | files: '^pyproject\.toml$' 66 | fail_fast: true 67 | 68 | - id: lint 69 | name: Run code linting 70 | entry: task lint -- --output-format=concise 71 | types: [ python ] 72 | language: system 73 | pass_filenames: false 74 | fail_fast: true 75 | stages: [ pre-commit, pre-push, manual ] 76 | 77 | - id: typecheck-mypy 78 | name: Run type checking with MyPy 79 | entry: env MYPY_PRETTY=0 task typecheck 80 | types: [ python ] 81 | language: system 82 | pass_filenames: false 83 | fail_fast: true 84 | stages: [ pre-push, manual ] # Run on push only to speed up commits 85 | 86 | - id: typecheck-ty 87 | name: Run type checking with Ty 88 | entry: task ty 89 | types: [ python ] 90 | language: system 91 | pass_filenames: false 92 | fail_fast: true 93 | stages: [ pre-commit, pre-push, manual ] 94 | 95 | - id: deptry 96 | name: Check dependency usage 97 | entry: task deptry 98 | language: system 99 | pass_filenames: false 100 | fail_fast: true 101 | stages: [ pre-push, manual ] # Run on push only to speed up commits 102 | 103 | - id: security-check 104 | name: Run security checks 105 | entry: task security-audit 106 | language: system 107 | pass_filenames: false 108 | fail_fast: true 109 | stages: [ pre-push, manual ] # Run on push only to speed up commits 110 | verbose: true 111 | 112 | # Meta hooks 113 | - repo: meta 114 | hooks: 115 | - id: check-hooks-apply 116 | - id: check-useless-excludes 117 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering a contribution to `waku`! 🎉 4 | 5 | This guide will help you get started and ensure a smooth process. 6 | 7 | ## Table of Contents 8 | 9 | - [Getting Started](#getting-started) 10 | - [Prerequisites](#prerequisites) 11 | - [Development Setup](#development-setup) 12 | - [Development Workflow](#development-workflow) 13 | - [Making Changes](#making-changes) 14 | - [Testing](#testing) 15 | - [Code Style](#code-style) 16 | - [Getting Help](#getting-help) 17 | - [First-time Contributors](#first-time-contributors) 18 | - [Issues](#issues) 19 | - [Project Structure](#project-structure) 20 | - [Commit Message Guidelines](#commit-message-guidelines) 21 | 22 | ## Getting Started 23 | 24 | ### Prerequisites 25 | 26 | Before you begin, ensure you have the following installed: 27 | 28 | - Python 3.11 or higher 29 | - [uv](https://docs.astral.sh/uv/getting-started/installation/) – a modern Python package manager 30 | - [Task](https://taskfile.dev/installation/) – a task runner for automating development workflows (we recommend setting up [auto-completion](https://taskfile.dev/installation/#setup-completions) for Task) 31 | - Git 32 | 33 | ### Development Setup 34 | 35 | 1. Fork and clone the repository: 36 | 37 | ```bash 38 | git clone git@github.com:/waku.git 39 | cd waku 40 | ``` 41 | 42 | 2. Install UV (if not already installed): 43 | 44 | ```bash 45 | # On macOS and Linux 46 | curl -LsSf https://astral.sh/uv/install.sh | sh 47 | 48 | # For other platforms, see: 49 | # https://docs.astral.sh/uv/getting-started/installation/ 50 | 51 | # If uv is already installed, ensure it's up to date: 52 | uv self update 53 | ``` 54 | 55 | 3. Install Task (if not already installed): 56 | 57 | ```bash 58 | # Using the install script 59 | sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin 60 | 61 | # For other installation options, see: 62 | # https://taskfile.dev/installation/ 63 | ``` 64 | 65 | 4. Set up the development environment: 66 | 67 | ```bash 68 | # Install dependencies and configure pre-commit hooks 69 | task install 70 | ``` 71 | 72 | > **Tip:** Run `task -l` after setup to verify everything is working and to see available commands. 73 | 74 | ## Development Workflow 75 | 76 | ### Making Changes 77 | 78 | 1. Fork the repository to your own GitHub account. 79 | 2. Clone your fork locally: 80 | 81 | ```bash 82 | git clone git@github.com:/waku.git 83 | cd waku 84 | ``` 85 | 86 | 3. Create a new branch for your changes: 87 | 88 | ```bash 89 | git checkout -b feat/your-feature-name 90 | ``` 91 | 92 | 4. Make your changes, following our [code style guidelines](#code-style). 93 | 5. Write or update tests for your changes. 94 | 6. Run all checks and ensure tests pass: 95 | 96 | ```bash 97 | task 98 | ``` 99 | 100 | 7. Commit your changes with clear, descriptive messages. 101 | 8. Push to your fork: 102 | 103 | ```bash 104 | git push origin feat/your-feature-name 105 | ``` 106 | 107 | 9. Open a pull request on GitHub. Link related issues in your PR description (e.g., "Fixes #123"). 108 | 10. Participate in the review process and make any requested changes. 109 | 110 | #### Pull Request Checklist 111 | 112 | - [ ] Tests added or updated 113 | - [ ] Documentation updated (if needed) 114 | - [ ] Code is formatted and linted 115 | - [ ] All checks pass 116 | - [ ] Type hints added or refined 117 | - [ ] Commit messages include a detailed description for the changelog 118 | 119 | ### Testing 120 | 121 | Ensure your changes are thoroughly tested by running the following commands: 122 | 123 | ```bash 124 | # Run all checks (recommended) 125 | task 126 | 127 | # Run linters and type checkers 128 | task check 129 | 130 | # Run specific checks 131 | task test # Run tests only 132 | task test:cov # Run tests with coverage 133 | task lint # Run linters only 134 | task format # Format code 135 | task typecheck # Run type checkers only 136 | ``` 137 | 138 | ### Code Style 139 | 140 | We use several tools to maintain code quality: 141 | 142 | - [Ruff](https://github.com/astral-sh/ruff) for linting and formatting 143 | - [MyPy](http://mypy-lang.org/) and [basedpyright](https://github.com/detachhead/basedpyright) for type checking 144 | - [pre-commit](https://pre-commit.com/) for running checks before commits and pushes 145 | 146 | **Key style guidelines:** 147 | 148 | - Maximum line length: 120 characters 149 | - Use explicit type annotations throughout the codebase 150 | - Follow [PEP 8](https://peps.python.org/pep-0008/) conventions 151 | - Write descriptive docstrings using the [Google style](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings) 152 | 153 | ## Getting Help 154 | 155 | If you have questions or need help, you can: 156 | 157 | - Open a [discussion](https://github.com/waku-py/waku/discussions) 158 | - Open an [issue](https://github.com/waku-py/waku/issues) for bugs or feature requests 159 | 160 | ## First-time Contributors 161 | 162 | - Look for issues labeled ["good first issue"](https://github.com/waku-py/waku/labels/good-first-issue) or ["help wanted"](https://github.com/waku-py/waku/labels/help-wanted). 163 | - Comment on the issue to let others know you're working on it. 164 | - Don't hesitate to ask questions if anything is unclear. 165 | 166 | ## Issues 167 | 168 | Before creating an issue: 169 | 170 | - Search existing issues to avoid duplicates. 171 | - Use the appropriate issue template for bug reports or feature requests. 172 | - Provide as much context as possible (e.g., steps to reproduce, environment details). 173 | 174 | Please follow the [bug report](https://github.com/waku-py/waku/issues/new?template=bug_report.md) and [feature request](https://github.com/waku-py/waku/issues/new?template=feature_request.md) templates when submitting issues. 175 | 176 | We welcome: 177 | 178 | - Bug reports 179 | - Feature requests 180 | - Documentation improvements 181 | - General questions or ideas 182 | 183 | ## Project Structure 184 | 185 | - `src/` – main source code 186 | - `tests/` – test suite 187 | - `docs/` – documentation 188 | - `Taskfile.yml` – development automation 189 | - `README.md` – project overview 190 | 191 | ## Commit Message Guidelines 192 | 193 | - Use clear, descriptive commit messages. 194 | - Example: `fix(core): handle edge case in dependency resolution` 195 | 196 | Thank you for contributing to `waku`! 🙏 197 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024-2025 waku-py 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | set: [ nounset ] 4 | 5 | vars: 6 | SOURCES: . 7 | 8 | env: 9 | COLUMNS: 120 10 | FORCE_COLOR: 1 11 | PRE_COMMIT_COLOR: always 12 | UV_NO_SYNC: 1 # Avoid syncing the virtual environment on `uv run` 13 | PYTHONPATH: . 14 | 15 | tasks: 16 | default: 17 | cmd: 18 | task: all 19 | 20 | .uv: 21 | desc: Check UV installation and lockfile status 22 | internal: true 23 | silent: true 24 | preconditions: 25 | - sh: command -v uv > /dev/null 2>&1 26 | msg: "⚠️ UV not found. Install from: https://docs.astral.sh/uv/getting-started/installation/" 27 | 28 | .uv-locked: 29 | desc: Check UV installation and lockfile status 30 | deps: [ .uv ] 31 | internal: true 32 | silent: true 33 | preconditions: 34 | - sh: uv lock --locked 35 | msg: "⚠️ Lockfile outdated. Run: task deps:sync" 36 | 37 | deps:install: 38 | aliases: [ install ] 39 | desc: Install the package, dependencies, and pre-commit for local development 40 | deps: [ .uv ] 41 | silent: true 42 | cmds: 43 | - task: deps:sync 44 | - uv run pre-commit install 45 | - echo "✅ Development environment setup complete" 46 | 47 | deps:sync: 48 | aliases: [ sync ] 49 | desc: Sync all project dependencies 50 | deps: [ .uv ] 51 | silent: true 52 | cmds: 53 | - uv sync --all-extras --all-groups {{.CLI_ARGS}} 54 | - echo "✅ Dependencies synced" 55 | 56 | deps:upgrade: 57 | aliases: [ upgrade ] 58 | desc: Upgrade all project dependencies and pre-commit hooks 59 | deps: [ .uv-locked ] 60 | silent: true 61 | cmds: 62 | - uv sync --all-extras --all-groups --upgrade 63 | - uv run pre-commit autoupdate 64 | - echo "✅ Dependencies upgraded" 65 | 66 | lint: 67 | desc: Lint code 68 | deps: [ .uv-locked ] 69 | cmds: 70 | - uv run ruff check {{.SOURCES}} {{.CLI_ARGS}} 71 | - uv run ruff format --check {{.SOURCES}} 72 | sources: 73 | - "**/*.py" 74 | - "pyproject.toml" 75 | generates: 76 | - ".ruff_cache/**/*" 77 | 78 | format: 79 | aliases: [ fmt ] 80 | desc: Format code 81 | deps: [ .uv-locked ] 82 | cmds: 83 | - uv run ruff check --fix {{.SOURCES}} 84 | - uv run ruff format {{.SOURCES}} 85 | sources: 86 | - "**/*.py" 87 | - "pyproject.toml" 88 | generates: 89 | - ".ruff_cache/**/*" 90 | 91 | typecheck: 92 | aliases: [ mypy ] 93 | desc: Type check code 94 | deps: [ .uv-locked ] 95 | cmds: 96 | - uv run mypy{{ if ne (env "MYPY_PRETTY") "0" }} --pretty{{end}} {{.SOURCES}} 97 | sources: 98 | - "**/*.py" 99 | generates: 100 | - ".mypy_cache/**/*" 101 | 102 | basedpyright: 103 | desc: Type check code with basedpyright 104 | deps: [ .uv-locked ] 105 | cmds: 106 | - uv run basedpyright 107 | 108 | ty: 109 | desc: Type check code with ty 110 | deps: [ .uv-locked ] 111 | cmds: 112 | - uv run ty check src tests 113 | 114 | check: 115 | aliases: [ ci ] 116 | desc: Lint & typecheck python source files 117 | deps: [ lint, typecheck ] 118 | 119 | codespell: 120 | desc: Use Codespell to do spellchecking 121 | deps: [ .uv-locked ] 122 | cmd: uv run pre-commit run codespell --all-files 123 | 124 | pre-commit-all: 125 | desc: Run all pre-commit hooks against all files 126 | deps: [ .uv-locked ] 127 | silent: true 128 | cmd: uv run pre-commit run --verbose --all-files --hook-stage manual 129 | 130 | security-audit: 131 | desc: Audit dependencies for vulnerabilities 132 | deps: [ .uv-locked ] 133 | silent: true 134 | cmd: uv export --no-emit-project --no-header --no-annotate | uv run pip-audit --require-hashes --disable-pip -r /dev/stdin 135 | env: 136 | PIP_AUDIT_PROGRESS_SPINNER: "off" 137 | 138 | deptry: 139 | desc: Find unused, missing and transitive dependencies in project 140 | deps: [ .uv-locked ] 141 | cmd: uv run deptry . 142 | 143 | gitlint: 144 | desc: Lint git commit 145 | deps: [ .uv ] 146 | cmd: uv run gitlint {{.CLI_ARGS}} 147 | 148 | test: 149 | desc: Run tests 150 | deps: [ .uv-locked ] 151 | cmd: uv run pytest {{.PYTEST_ARGS}} {{.CLI_ARGS}} 152 | 153 | test:cov: 154 | aliases: [ 'test:ci', 'test:all' ] 155 | desc: Run tests with coverage 156 | deps: [ .uv-locked ] 157 | vars: 158 | PYTEST_ARGS: --cov 159 | cmds: 160 | - task: test 161 | vars: 162 | PYTEST_ARGS: 163 | ref: .PYTEST_ARGS 164 | 165 | all: 166 | desc: Run the full set of checks 167 | cmds: 168 | - task: check 169 | - task: codespell 170 | - task: test:cov 171 | 172 | clean: 173 | desc: Clear local caches and build artifacts 174 | cmds: 175 | - rm -rf `find . -name __pycache__` 176 | - rm -f `find . -type f -name '*.py[co]'` 177 | - rm -f `find . -type f -name '*~'` 178 | - rm -f `find . -type f -name '.*~'` 179 | - rm -rf `find . -name '.*cache'` 180 | - rm -rf .task 181 | - rm -rf htmlcov 182 | - rm -f .coverage 183 | - rm -f .coverage.* 184 | - rm -f coverage.xml 185 | - rm -rf *.egg-info 186 | - rm -rf build 187 | - rm -rf dist 188 | - rm -rf site 189 | -------------------------------------------------------------------------------- /config/release-templates/.components/changelog_header.md.j2: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | {% if ctx.changelog_mode == "update" 4 | %}{# # Modified insertion flag to insert a changelog header directly 5 | # which convienently puts the insertion flag incognito when reading raw RST 6 | #}{{ 7 | insertion_flag ~ "\n" 8 | 9 | }}{% endif 10 | %} 11 | -------------------------------------------------------------------------------- /config/release-templates/.components/changelog_init.md.j2: -------------------------------------------------------------------------------- 1 | {# 2 | This changelog template initializes a full changelog for the project, 3 | it follows the following logic: 4 | 1. Header 5 | 2. Any Unreleased Details (uncommon) 6 | 3. all previous releases except the very first release 7 | 4. the first release 8 | 9 | #}{# 10 | # # Header 11 | #}{% include "changelog_header.md.j2" 12 | -%}{# 13 | # # Any Unreleased Details (uncommon) 14 | #}{% include "unreleased_changes.md.j2" 15 | -%}{# 16 | # # Since this is initialization, we are generating all the previous 17 | # # release notes per version. The very first release notes is specialized. 18 | # # We also have non-conformative commits, so insert manual write-ups. 19 | #}{% if releases | length > 0 20 | %}{% for release in releases 21 | %}{% if loop.last 22 | %}{{ "\n" 23 | }}{% include "first_release.md.j2" 24 | -%}{{ "\n" 25 | }} 26 | {% else 27 | %}{{ "\n" 28 | }}{% include "versioned_changes.md.j2" 29 | -%}{{ "\n" 30 | }}{% endif 31 | %}{% endfor 32 | %}{% endif 33 | %} 34 | -------------------------------------------------------------------------------- /config/release-templates/.components/changelog_update.md.j2: -------------------------------------------------------------------------------- 1 | {# 2 | This Update changelog template uses the following logic: 3 | 4 | 1. Read previous changelog file (ex. project_root/CHANGELOG.md) 5 | 2. Split on insertion flag (ex. ) 6 | 3. Print top half of previous changelog 7 | 3. New Changes (unreleased commits & newly released) 8 | 4. Print bottom half of previous changelog 9 | 10 | Note: if a previous file was not found, it does not write anything at the bottom 11 | but render does NOT fail 12 | 13 | #}{% set prev_changelog_contents = prev_changelog_file | read_file | safe 14 | %}{% set changelog_parts = prev_changelog_contents.split(insertion_flag, maxsplit=1) 15 | %}{# 16 | #}{% if changelog_parts | length < 2 17 | %}{# # insertion flag was not found, check if the file was empty or did not exist 18 | #}{% if prev_changelog_contents | length > 0 19 | %}{# # File has content but no insertion flag, therefore, file will not be updated 20 | #}{{ changelog_parts[0] 21 | }}{% else 22 | %}{# # File was empty or did not exist, therefore, it will be created from scratch 23 | #}{% include "changelog_init.md.j2" 24 | %}{% endif 25 | %}{% else 26 | %}{# 27 | # Previous Changelog Header 28 | # - Depending if there is header content, then it will separate the insertion flag 29 | # with a newline from header content, otherwise it will just print the insertion flag 30 | #}{% set prev_changelog_top = changelog_parts[0] | trim 31 | %}{% if prev_changelog_top | length > 0 32 | %}{{ 33 | "%s\n\n%s\n" | format(prev_changelog_top, insertion_flag | trim) | safe 34 | 35 | }}{% else 36 | %}{{ 37 | "%s\n" | format(insertion_flag | trim) | safe 38 | 39 | }}{% endif 40 | %}{# 41 | # Any Unreleased Details (uncommon) 42 | #}{% include "unreleased_changes.md.j2" 43 | -%}{# 44 | #}{% if releases | length > 0 45 | %}{# # Latest Release Details 46 | #}{% set release = releases[0] 47 | %}{# 48 | #}{% if releases | length == 1 and ctx.mask_initial_release 49 | %}{# # First Release detected 50 | #}{{ "\n" 51 | }}{%- include "first_release.md.j2" 52 | -%}{{ "\n" 53 | }}{# 54 | #}{% elif "# " ~ release.version.as_semver_tag() ~ " " not in changelog_parts[1] 55 | %}{# # The release version is not already in the changelog so we add it 56 | #}{{ "\n" 57 | }}{%- include "versioned_changes.md.j2" 58 | -%}{{ "\n" 59 | }}{# 60 | #}{% endif 61 | %}{% endif 62 | %}{# 63 | # Previous Changelog Footer 64 | # - skips printing footer if empty, which happens when the insertion_flag 65 | # was at the end of the file (ignoring whitespace) 66 | #}{% set previous_changelog_bottom = changelog_parts[1] | trim 67 | %}{% if previous_changelog_bottom | length > 0 68 | %}{{ "\n%s\n" | format(previous_changelog_bottom) 69 | }}{% endif 70 | %}{% endif 71 | %} 72 | -------------------------------------------------------------------------------- /config/release-templates/.components/changes.md.j2: -------------------------------------------------------------------------------- 1 | {% from 'macros.common.j2' import apply_alphabetical_ordering_by_brk_descriptions 2 | %}{% from 'macros.common.j2' import apply_alphabetical_ordering_by_descriptions 3 | %}{% from 'macros.common.j2' import apply_alphabetical_ordering_by_release_notices 4 | %}{% from 'macros.common.j2' import emoji_map, format_breaking_changes_description 5 | %}{% from 'macros.common.j2' import format_release_notice, section_heading_order 6 | %}{% from 'macros.common.j2' import section_heading_translations 7 | %}{% from 'macros.md.j2' import format_commit_summary_line 8 | %}{# 9 | EXAMPLE: 10 | 11 | ### ✨ Features 12 | 13 | - Add new feature ([#10](https://domain.com/namespace/repo/pull/10), 14 | [`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) 15 | 16 | - **scope**: Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) 17 | 18 | ### 🪲 Bug Fixes 19 | 20 | - Fix bug ([#11](https://domain.com/namespace/repo/pull/11), 21 | [`abcdef1`](https://domain.com/namespace/repo/commit/HASH)) 22 | 23 | ### 💥 Breaking Changes 24 | 25 | - With the change _____, the change causes ___ effect. Ultimately, this section 26 | it is a more detailed description of the breaking change. With an optional 27 | scope prefix like the commit messages above. 28 | 29 | - **scope**: this breaking change has a scope to identify the part of the code that 30 | this breaking change applies to for better context. 31 | 32 | ### 💡 Additional Release Information 33 | 34 | - This is a release note that provides additional information about the release 35 | that is not a breaking change or a feature/bug fix. 36 | 37 | - **scope**: this release note has a scope to identify the part of the code that 38 | this release note applies to for better context. 39 | 40 | #}{% set max_line_width = max_line_width | default(100) 41 | %}{% set hanging_indent = hanging_indent | default(2) 42 | %}{# 43 | #}{% for type_, commits in commit_objects if type_ != "unknown" 44 | %}{# PREPROCESS COMMITS (order by description & format description line) 45 | #}{% set ns = namespace(commits=commits) 46 | %}{% set _ = apply_alphabetical_ordering_by_descriptions(ns) 47 | %}{# 48 | #}{% set commit_descriptions = [] 49 | %}{# 50 | #}{% for commit in ns.commits 51 | %}{# # Add reference links to the commit summary line 52 | #}{% set description = "- %s" | format(format_commit_summary_line(commit)) 53 | %}{% set description = description | autofit_text_width(max_line_width, hanging_indent) 54 | %}{% set _ = commit_descriptions.append(description) 55 | %}{% endfor 56 | %}{# 57 | # # PRINT SECTION (header & commits) 58 | #}{% if commit_descriptions | length > 0 59 | %}{{ "\n" 60 | }}{{ "### %s %s\n" | format(emoji_map[type_], type_ | title) 61 | }}{{ "\n" 62 | }}{{ "%s\n" | format(commit_descriptions | unique | join("\n\n")) 63 | }}{% endif 64 | %}{% endfor 65 | %}{# 66 | # Determine if there are any breaking change commits by filtering the list by breaking descriptions 67 | # commit_objects is a list of tuples [("Features", [ParsedCommit(), ...]), ("Bug Fixes", [ParsedCommit(), ...])] 68 | # HOW: Filter out breaking change commits that have no breaking descriptions 69 | # 1. Re-map the list to only the list of commits under the breaking category from the list of tuples 70 | # 2. Peel off the outer list to get a list of ParsedCommit objects 71 | # 3. Filter the list of ParsedCommits to only those with a breaking description 72 | #}{% set breaking_commits = commit_objects | map(attribute="1.0") 73 | %}{% set breaking_commits = breaking_commits | rejectattr("error", "defined") | selectattr("breaking_descriptions.0") | list 74 | %}{# 75 | #}{% if breaking_commits | length > 0 76 | %}{# PREPROCESS COMMITS 77 | #}{% set brk_ns = namespace(commits=breaking_commits) 78 | %}{% set _ = apply_alphabetical_ordering_by_brk_descriptions(brk_ns) 79 | %}{# 80 | #}{% set brking_descriptions = [] 81 | %}{# 82 | #}{% for commit in brk_ns.commits 83 | %}{% set full_description = "- %s" | format( 84 | format_breaking_changes_description(commit).split("\n\n") | join("\n\n- ") 85 | ) 86 | %}{% set _ = brking_descriptions.append( 87 | full_description | autofit_text_width(max_line_width, hanging_indent) 88 | ) 89 | %}{% endfor 90 | %}{# 91 | # # PRINT BREAKING CHANGE DESCRIPTIONS (header & descriptions) 92 | #}{{ "\n" 93 | }}{{ "### %s Breaking Changes\n" | format(emoji_map["breaking"]) 94 | }}{{ 95 | "\n%s\n" | format(brking_descriptions | unique | join("\n\n")) 96 | }}{# 97 | #}{% endif 98 | %}{# 99 | # Determine if there are any commits with release notice information by filtering the list by release_notices 100 | # commit_objects is a list of tuples [("Features", [ParsedCommit(), ...]), ("Bug Fixes", [ParsedCommit(), ...])] 101 | # HOW: Filter out commits that have no release notices 102 | # 1. Re-map the list to only the list of commits from the list of tuples 103 | # 2. Peel off the outer list to get a list of ParsedCommit objects 104 | # 3. Filter the list of ParsedCommits to only those with a release notice 105 | #}{% set notice_commits = commit_objects | map(attribute="1.0") 106 | %}{% set notice_commits = notice_commits | rejectattr("error", "defined") | selectattr("release_notices.0") | list 107 | %}{# 108 | #}{% if notice_commits | length > 0 109 | %}{# PREPROCESS COMMITS 110 | #}{% set notice_ns = namespace(commits=notice_commits) 111 | %}{% set _ = apply_alphabetical_ordering_by_release_notices(notice_ns) 112 | %}{# 113 | #}{% set release_notices = [] 114 | %}{# 115 | #}{% for commit in notice_ns.commits 116 | %}{% set full_description = "- %s" | format( 117 | format_release_notice(commit).split("\n\n") | join("\n\n- ") 118 | ) 119 | %}{% set _ = release_notices.append( 120 | full_description | autofit_text_width(max_line_width, hanging_indent) 121 | ) 122 | %}{% endfor 123 | %}{# 124 | # # PRINT RELEASE NOTICE INFORMATION (header & descriptions) 125 | #}{{ "\n" 126 | }}{{ "### %s Additional Release Information\n" | format(emoji_map["release_note"]) 127 | }}{{ 128 | "\n%s\n" | format(release_notices | unique | join("\n\n")) 129 | }}{# 130 | #}{% endif 131 | %} 132 | -------------------------------------------------------------------------------- /config/release-templates/.components/first_release.md.j2: -------------------------------------------------------------------------------- 1 | {# EXAMPLE: 2 | 3 | ## vX.X.X (YYYY-MMM-DD) 4 | 5 | _This release is published under the MIT License._ # Release Notes Only 6 | 7 | - Initial Release 8 | 9 | #}{{ 10 | "## %s (%s)\n" | format( 11 | release.version.as_semver_tag(), 12 | release.tagged_date.strftime("%Y-%m-%d") 13 | ) 14 | }}{% if license_name is defined and license_name 15 | %}{{ "\n_This release is published under the %s License._\n" | format(license_name) 16 | }}{% endif 17 | %} 18 | - Initial Release 19 | -------------------------------------------------------------------------------- /config/release-templates/.components/macros.common.j2: -------------------------------------------------------------------------------- 1 | {# TODO: move to configuration for user to modify #} 2 | {% set section_heading_translations = { 3 | 'feat': 'features', 4 | 'fix': 'bug fixes', 5 | 'perf': 'performance improvements', 6 | 'docs': 'documentation', 7 | 'build': 'build system', 8 | 'refactor': 'refactoring', 9 | 'test': 'testing', 10 | 'ci': 'continuous integration', 11 | 'chore': 'chores', 12 | 'style': 'code style', 13 | } 14 | %} 15 | 16 | {% set section_heading_order = section_heading_translations.values() %} 17 | 18 | {% set emoji_map = { 19 | 'breaking': '💥', 20 | 'features': '✨', 21 | 'bug fixes': '🪲', 22 | 'performance improvements': '⚡', 23 | 'documentation': '📖', 24 | 'build system': '⚙️', 25 | 'refactoring': '♻️', 26 | 'testing': '✅', 27 | 'continuous integration': '🤖', 28 | 'chores': '🧹', 29 | 'code style': '🎨', 30 | 'unknown': '❗', 31 | 'release_note': '💡', 32 | } %} 33 | 34 | 35 | {# 36 | MACRO: Capitalize the first letter of a string only 37 | #}{% macro capitalize_first_letter_only(sentence) 38 | %}{{ (sentence[0] | upper) ~ sentence[1:] 39 | }}{% endmacro 40 | %} 41 | 42 | 43 | {# 44 | MACRO: format a commit descriptions list by: 45 | - Capitalizing the first line of the description 46 | - Adding an optional scope prefix 47 | - Joining the rest of the descriptions with a double newline 48 | #}{% macro format_attr_paragraphs(commit, attribute) 49 | %}{# NOTE: requires namespace because of the way Jinja2 handles variable scoping with loops 50 | #}{% set ns = namespace(full_description="") 51 | %}{# 52 | #}{% if commit.error is undefined 53 | %}{% for paragraph in commit | attr(attribute) 54 | %}{% if paragraph | trim | length > 0 55 | %}{# 56 | #}{% set ns.full_description = [ 57 | ns.full_description, 58 | capitalize_first_letter_only(paragraph) | trim | safe, 59 | ] | join("\n\n") 60 | %}{# 61 | #}{% endif 62 | %}{% endfor 63 | %}{# 64 | #}{% set ns.full_description = ns.full_description | trim 65 | %}{# 66 | #}{% if commit.scope 67 | %}{% set ns.full_description = "**%s**: %s" | format( 68 | commit.scope, ns.full_description 69 | ) 70 | %}{% endif 71 | %}{% endif 72 | %}{# 73 | #}{{ ns.full_description 74 | }}{% endmacro 75 | %} 76 | 77 | 78 | {# 79 | MACRO: format the breaking changes description by: 80 | - Capitalizing the description 81 | - Adding an optional scope prefix 82 | #}{% macro format_breaking_changes_description(commit) 83 | %}{{ format_attr_paragraphs(commit, 'breaking_descriptions') 84 | }}{% endmacro 85 | %} 86 | 87 | 88 | {# 89 | MACRO: format the release notice by: 90 | - Capitalizing the description 91 | - Adding an optional scope prefix 92 | #}{% macro format_release_notice(commit) 93 | %}{{ format_attr_paragraphs(commit, "release_notices") 94 | }}{% endmacro 95 | %} 96 | 97 | 98 | {# 99 | MACRO: order commits alphabetically by scope and attribute 100 | - Commits are sorted based on scope and then the attribute alphabetically 101 | - Commits without scope are placed first and sorted alphabetically by the attribute 102 | - parameter: ns (namespace) object with a commits list 103 | - parameter: attr (string) attribute to sort by 104 | - returns None but modifies the ns.commits list in place 105 | #}{% macro order_commits_alphabetically_by_scope_and_attr(ns, attr) 106 | %}{% set ordered_commits = [] 107 | %}{# 108 | # # Eliminate any ParseError commits from input set 109 | #}{% set filtered_commits = ns.commits | rejectattr("error", "defined") | list 110 | %}{# 111 | # # grab all commits with no scope and sort alphabetically by attr 112 | #}{% for commit in filtered_commits | rejectattr("scope") | sort(attribute=attr) 113 | %}{% set _ = ordered_commits.append(commit) 114 | %}{% endfor 115 | %}{# 116 | # # grab all commits with a scope and sort alphabetically by the scope and then attr 117 | #}{% for commit in filtered_commits | selectattr("scope") | sort(attribute=(['scope', attr] | join(","))) 118 | %}{% set _ = ordered_commits.append(commit) 119 | %}{% endfor 120 | %}{# 121 | # # Return the ordered commits 122 | #}{% set ns.commits = ordered_commits 123 | %}{% endmacro 124 | %} 125 | 126 | 127 | {# 128 | MACRO: apply smart ordering of commits objects based on alphabetized summaries and then scopes 129 | - Commits are sorted based on the commit type and the commit message 130 | - Commits are grouped by the commit type 131 | - parameter: ns (namespace) object with a commits list 132 | - returns None but modifies the ns.commits list in place 133 | #}{% macro apply_alphabetical_ordering_by_descriptions(ns) 134 | %}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'descriptions.0') 135 | %}{% endmacro 136 | %} 137 | 138 | 139 | {# 140 | MACRO: apply smart ordering of commits objects based on alphabetized breaking changes and then scopes 141 | - Commits are sorted based on the commit type and the commit message 142 | - Commits are grouped by the commit type 143 | - parameter: ns (namespace) object with a commits list 144 | - returns None but modifies the ns.commits list in place 145 | #}{% macro apply_alphabetical_ordering_by_brk_descriptions(ns) 146 | %}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'breaking_descriptions.0') 147 | %}{% endmacro 148 | %} 149 | 150 | 151 | {# 152 | MACRO: apply smart ordering of commits objects based on alphabetized release notices and then scopes 153 | - Commits are sorted based on the commit type and the commit message 154 | - Commits are grouped by the commit type 155 | - parameter: ns (namespace) object with a commits list 156 | - returns None but modifies the ns.commits list in place 157 | #}{% macro apply_alphabetical_ordering_by_release_notices(ns) 158 | %}{% set _ = order_commits_alphabetically_by_scope_and_attr(ns, 'release_notices.0') 159 | %}{% endmacro 160 | %} 161 | -------------------------------------------------------------------------------- /config/release-templates/.components/macros.md.j2: -------------------------------------------------------------------------------- 1 | {% from 'macros.common.j2' import capitalize_first_letter_only %} 2 | 3 | 4 | {# 5 | MACRO: format a inline link reference in Markdown 6 | #}{% macro format_link(link, label) 7 | %}{{ "[%s](%s)" | format(label, link) 8 | }}{% endmacro 9 | %} 10 | 11 | 12 | {# 13 | MACRO: commit message links or PR/MR links of commit 14 | #}{% macro commit_msg_links(commit) 15 | %}{% if commit.error is undefined 16 | %}{# 17 | # # Initialize variables 18 | #}{% set link_references = [] 19 | %}{% set summary_line = capitalize_first_letter_only( 20 | commit.descriptions[0] | safe 21 | ) 22 | %}{# 23 | #}{% if commit.linked_merge_request != "" 24 | %}{# # Add PR references with a link to the PR 25 | #}{% set _ = link_references.append( 26 | format_link( 27 | commit.linked_merge_request | pull_request_url, 28 | "PR" ~ commit.linked_merge_request 29 | ) 30 | ) 31 | %}{% endif 32 | %}{# 33 | # # DEFAULT: Always include the commit hash as a link 34 | #}{% set _ = link_references.append( 35 | format_link( 36 | commit.hexsha | commit_hash_url, 37 | "`%s`" | format(commit.short_hash) 38 | ) 39 | ) 40 | %}{# 41 | #}{% set formatted_links = "" 42 | %}{% if link_references | length > 0 43 | %}{% set formatted_links = " (%s)" | format(link_references | join(", ")) 44 | %}{% endif 45 | %}{# 46 | # Return the modified summary_line 47 | #}{{ summary_line ~ formatted_links 48 | }}{% endif 49 | %}{% endmacro 50 | %} 51 | 52 | 53 | {# 54 | MACRO: format commit summary line 55 | #}{% macro format_commit_summary_line(commit) 56 | %}{# # Check for Parsing Error 57 | #}{% if commit.error is undefined 58 | %}{# 59 | # # Add any message links to the commit summary line 60 | #}{% set summary_line = commit_msg_links(commit) 61 | %}{# 62 | #}{% if commit.scope 63 | %}{% set summary_line = "**%s**: %s" | format(commit.scope, summary_line) 64 | %}{% endif 65 | %}{# 66 | # # Return the modified summary_line 67 | #}{{ summary_line 68 | }}{# 69 | #}{% else 70 | %}{# # Return the first line of the commit if there was a Parsing Error 71 | #}{{ (commit.commit.message | string).split("\n", maxsplit=1)[0] 72 | }}{% endif 73 | %}{% endmacro 74 | %} 75 | -------------------------------------------------------------------------------- /config/release-templates/.components/unreleased_changes.md.j2: -------------------------------------------------------------------------------- 1 | {% if unreleased_commits | length > 0 2 | %}{{ "\n## Unreleased\n" 3 | }}{% set commit_objects = unreleased_commits 4 | %}{% include "changes.md.j2" 5 | -%}{{ "\n" 6 | }}{% endif 7 | %} 8 | -------------------------------------------------------------------------------- /config/release-templates/.components/versioned_changes.md.j2: -------------------------------------------------------------------------------- 1 | {# EXAMPLE: 2 | 3 | ## vX.X.X (YYYY-MMM-DD) 4 | 5 | _This release is published under the MIT License._ # Release Notes Only 6 | 7 | {{ change_sections }} 8 | 9 | #}{{ 10 | "## %s (%s)\n" | format( 11 | release.version.as_semver_tag(), 12 | release.tagged_date.strftime("%Y-%m-%d") 13 | ) 14 | }}{% if license_name is defined and license_name 15 | %}{{ "\n_This release is published under the %s License._\n" | format(license_name) 16 | }}{% endif 17 | %}{# 18 | #}{% set commit_objects = release["elements"] | dictsort 19 | %}{% include "changes.md.j2" 20 | -%} 21 | -------------------------------------------------------------------------------- /config/release-templates/.release_notes.md.j2: -------------------------------------------------------------------------------- 1 | {% from ".components/macros.md.j2" import format_link 2 | %}{# EXAMPLE: 3 | 4 | ## v1.0.0 (2020-01-01) 5 | 6 | _This release is published under the MIT License._ 7 | 8 | ### ✨ Features 9 | 10 | - Add new feature ([PR#10](https://domain.com/namespace/repo/pull/10), [`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) 11 | 12 | - **scope**: Add new feature ([`abcdef0`](https://domain.com/namespace/repo/commit/HASH)) 13 | 14 | ### 🪲 Bug Fixes 15 | 16 | - Fix bug ([PR#11](https://domain.com/namespace/repo/pull/11), [`abcdef1`](https://domain.com/namespace/repo/commit/HASH)) 17 | 18 | ### 💥 Breaking Changes 19 | 20 | - With the change _____, the change causes ___ effect. Ultimately, this section it is a more detailed description of the breaking change. With an optional scope prefix like the commit messages above. 21 | 22 | - **scope**: this breaking change has a scope to identify the part of the code that this breaking change applies to for better context. 23 | 24 | ### 💡 Additional Release Information 25 | 26 | - This is a release note that provides additional information about the release that is not a breaking change or a feature/bug fix. 27 | 28 | - **scope**: this release note has a scope to identify the part of the code that this release note applies to for better context. 29 | 30 | ### ✅ Resolved Issues 31 | 32 | - [#000](https://domain.com/namespace/repo/issues/000): _Title_ 33 | 34 | --- 35 | 36 | **Detailed Changes**: [vX.X.X...vX.X.X](https://domain.com/namespace/repo/compare/vX.X.X...vX.X.X) 37 | 38 | --- 39 | 40 | **Installable artifacts are available from**: 41 | 42 | - [PyPi Registry](https://pypi.org/project/package_name/x.x.x) 43 | - [GitHub Release Assets](https://github.com/namespace/repo/releases/tag/vX.X.X) 44 | 45 | #}{# # Set line width to 1000 to avoid wrapping as GitHub will handle it 46 | #}{% set max_line_width = max_line_width | default(1000) 47 | %}{% set hanging_indent = hanging_indent | default(2) 48 | %}{% set license_name = license_name | default("", True) 49 | %}{% set releases = context.history.released.values() | list 50 | %}{% set curr_release_index = releases.index(release) 51 | %}{# 52 | #}{% if mask_initial_release and curr_release_index == releases | length - 1 53 | %}{# # On a first release, generate our special message 54 | #}{% include ".components/first_release.md.j2" 55 | %}{% else 56 | %}{# # Not the first release so generate notes normally 57 | #}{% include ".components/versioned_changes.md.j2" 58 | -%}{# 59 | # # If there are any commits that resolve issues, list out the issues with links 60 | #}{% set issue_resolving_commits = [] 61 | %}{% for commits in release["elements"].values() 62 | %}{% set _ = issue_resolving_commits.extend( 63 | commits | rejectattr("error", "defined") | selectattr("linked_issues") 64 | ) 65 | %}{% endfor 66 | %}{% if issue_resolving_commits | length > 0 67 | %}{{ 68 | "\n### ✅ Resolved Issues\n" 69 | }}{# 70 | #}{% set issue_numbers = [] 71 | %}{% for linked_issues in issue_resolving_commits | map(attribute="linked_issues") 72 | %}{% set _ = issue_numbers.extend(linked_issues) 73 | %}{% endfor 74 | %}{% for issue_num in issue_numbers | unique | sort_numerically 75 | %}{{ 76 | "\n- %s: _Title_\n" | format(format_link(issue_num | issue_url, issue_num)) 77 | }}{# 78 | #}{% endfor 79 | %}{% endif 80 | %}{# 81 | #}{% set prev_release_index = curr_release_index + 1 82 | %}{# 83 | #}{% if 'compare_url' is filter and prev_release_index < releases | length 84 | %}{% set prev_version_tag = releases[prev_release_index].version.as_tag() 85 | %}{% set new_version_tag = release.version.as_tag() 86 | %}{% set version_compare_url = prev_version_tag | compare_url(new_version_tag) 87 | %}{% set detailed_changes_link = '[{}...{}]({})'.format( 88 | prev_version_tag, new_version_tag, version_compare_url 89 | ) 90 | %}{{ "\n" 91 | }}{{ "---\n" 92 | }}{{ "\n" 93 | }}{{ "**Detailed Changes**: %s" | format(detailed_changes_link) 94 | }}{{ "\n" 95 | }}{% endif 96 | %}{% endif 97 | %}{# 98 | #} 99 | --- 100 | 101 | **Installable artifacts are available from**: 102 | 103 | {{ 104 | "- %s" | format( 105 | format_link( 106 | repo_name | create_pypi_url(release.version | string), 107 | "PyPi Registry", 108 | ) 109 | ) 110 | }} 111 | 112 | {{ 113 | "- %s" | format( 114 | format_link( 115 | release.version.as_tag() | create_release_url, 116 | "{vcs_name} Release Assets" | format_w_official_vcs_name, 117 | ) 118 | ) 119 | }} 120 | -------------------------------------------------------------------------------- /config/release-templates/CHANGELOG.md.j2: -------------------------------------------------------------------------------- 1 | {# 2 | This changelog template controls which changelog creation occurs 3 | based on which mode is provided. 4 | 5 | Modes: 6 | - init: Initialize a full changelog from scratch 7 | - update: Insert new version details where the placeholder exists in the current changelog 8 | 9 | #}{% set insertion_flag = ctx.changelog_insertion_flag 10 | %}{% set unreleased_commits = ctx.history.unreleased | dictsort 11 | %}{% set releases = ctx.history.released.values() | list 12 | %}{# 13 | #}{% if ctx.changelog_mode == "init" 14 | %}{% include ".components/changelog_init.md.j2" 15 | %}{# 16 | #}{% elif ctx.changelog_mode == "update" 17 | %}{% set prev_changelog_file = ctx.prev_changelog_file 18 | %}{% include ".components/changelog_update.md.j2" 19 | %}{# 20 | #}{% endif 21 | %} 22 | -------------------------------------------------------------------------------- /docs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/__init__.py -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Changelog 3 | hide: 4 | - navigation 5 | --- 6 | 7 | --8<-- "CHANGELOG.md" 8 | -------------------------------------------------------------------------------- /docs/code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/code/__init__.py -------------------------------------------------------------------------------- /docs/code/getting_started/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/code/getting_started/__init__.py -------------------------------------------------------------------------------- /docs/code/getting_started/application.py: -------------------------------------------------------------------------------- 1 | from waku import WakuApplication, WakuFactory, module 2 | 3 | from app.settings import ConfigModule 4 | from app.greetings.module import GreetingModule 5 | from app.users.module import UserModule 6 | 7 | 8 | @module( 9 | # Import all top-level modules 10 | imports=[ 11 | ConfigModule.register(env='dev'), 12 | GreetingModule, 13 | UserModule, 14 | ], 15 | ) 16 | class AppModule: 17 | pass 18 | 19 | 20 | def bootstrap_application() -> WakuApplication: 21 | return WakuFactory(AppModule).create() 22 | -------------------------------------------------------------------------------- /docs/code/getting_started/greetings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/code/getting_started/greetings/__init__.py -------------------------------------------------------------------------------- /docs/code/getting_started/greetings/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class Greeting: 6 | language: str 7 | template: str 8 | -------------------------------------------------------------------------------- /docs/code/getting_started/greetings/module.py: -------------------------------------------------------------------------------- 1 | from waku import module 2 | from waku.di import Singleton 3 | 4 | from app.modules.greetings.services import GreetingService 5 | 6 | 7 | @module( 8 | providers=[Singleton(GreetingService)], 9 | exports=[GreetingService], 10 | ) 11 | class GreetingModule: 12 | pass 13 | -------------------------------------------------------------------------------- /docs/code/getting_started/greetings/services.py: -------------------------------------------------------------------------------- 1 | from app.config import AppConfig 2 | from app.modules.greetings.models import Greeting 3 | 4 | 5 | class GreetingService: 6 | def __init__(self, config: AppConfig) -> None: 7 | self.config = config 8 | self.greetings: dict[str, Greeting] = { 9 | 'en': Greeting(language='en', template='Hello, {}!'), 10 | 'es': Greeting(language='es', template='¡Hola, {}!'), 11 | 'fr': Greeting(language='fr', template='Bonjour, {}!'), 12 | } 13 | 14 | def get_greeting(self, language: str = 'en') -> Greeting: 15 | # If in debug mode and language not found, return default 16 | if self.config.debug and language not in self.greetings: 17 | return self.greetings['en'] 18 | return self.greetings.get(language, self.greetings['en']) 19 | 20 | def greet(self, name: str, language: str = 'en') -> str: 21 | greeting = self.get_greeting(language) 22 | return greeting.template.format(name) 23 | 24 | def available_languages(self) -> list[str]: 25 | return list(self.greetings.keys()) 26 | -------------------------------------------------------------------------------- /docs/code/getting_started/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from waku.di import Injected, inject 4 | 5 | from app.application import bootstrap_application 6 | from app.modules.users.services import UserService 7 | from app.modules.greetings.services import GreetingService 8 | 9 | 10 | @inject 11 | async def greet_user_by_id( 12 | user_id: str, 13 | user_service: Injected[UserService], 14 | greeting_service: Injected[GreetingService], 15 | ) -> str: 16 | user = user_service.get_user(user_id) 17 | if not user: 18 | return f'User {user_id} not found' 19 | 20 | return greeting_service.greet(name=user.name, language=user.preferred_language) 21 | 22 | 23 | async def main() -> None: 24 | application = bootstrap_application() 25 | 26 | async with application, application.container() as container: 27 | # Greet different users 28 | for user_id in ['1', '2', '3', '4']: # '4' doesn't exist 29 | greeting = await greet_user_by_id(user_id) # type: ignore[call-arg] 30 | print(greeting) 31 | 32 | # Get service directly for demonstration 33 | greeting_service = await container.get(GreetingService) 34 | print(f'Available languages: {greeting_service.available_languages()}') 35 | 36 | 37 | if __name__ == '__main__': 38 | asyncio.run(main()) 39 | -------------------------------------------------------------------------------- /docs/code/getting_started/settings.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Literal 3 | 4 | from waku import DynamicModule, module 5 | from waku.di import Object 6 | 7 | Environment = Literal['dev', 'prod'] 8 | 9 | 10 | # You may consider using `pydantic-settings` or similar libs for settings management 11 | @dataclass(kw_only=True) 12 | class AppSettings: 13 | environment: Environment 14 | debug: bool 15 | 16 | 17 | @module(is_global=True) 18 | class ConfigModule: 19 | @classmethod 20 | def register(cls, env: Environment) -> DynamicModule: 21 | settings = AppSettings( 22 | environment=env, 23 | debug=env == 'dev', 24 | ) 25 | return DynamicModule( 26 | parent_module=cls, 27 | providers=[Object(settings)], 28 | ) 29 | -------------------------------------------------------------------------------- /docs/code/getting_started/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/code/getting_started/users/__init__.py -------------------------------------------------------------------------------- /docs/code/getting_started/users/models.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | @dataclass 5 | class User: 6 | id: str 7 | name: str 8 | preferred_language: str = 'en' 9 | -------------------------------------------------------------------------------- /docs/code/getting_started/users/module.py: -------------------------------------------------------------------------------- 1 | from waku import module 2 | from waku.di import Scoped 3 | 4 | from app.modules.users.services import UserService 5 | 6 | 7 | @module( 8 | providers=[Scoped(UserService)], 9 | exports=[UserService], 10 | ) 11 | class UserModule: 12 | pass 13 | -------------------------------------------------------------------------------- /docs/code/getting_started/users/services.py: -------------------------------------------------------------------------------- 1 | from app.modules.users.models import User 2 | 3 | 4 | class UserService: 5 | def __init__(self) -> None: 6 | # Mock database 7 | self.users: dict[str, User] = { 8 | '1': User(id='1', name='Alice', preferred_language='en'), 9 | '2': User(id='2', name='Bob', preferred_language='fr'), 10 | '3': User(id='3', name='Carlos', preferred_language='es'), 11 | } 12 | 13 | def get_user(self, user_id: str) -> User | None: 14 | return self.users.get(user_id) 15 | -------------------------------------------------------------------------------- /docs/code/providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/code/providers/__init__.py -------------------------------------------------------------------------------- /docs/code/providers/manual_di.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | # Use an interface to define contract for clients 5 | # This allows us injecting different implementations 6 | class IClient(ABC): 7 | @abstractmethod 8 | def request(self, url: str) -> str: 9 | pass 10 | 11 | 12 | # Regular implementation 13 | class RealClient(IClient): 14 | def request(self, url: str) -> str: 15 | # Some HTTP requesting logic 16 | return f'"{url}" call result' 17 | 18 | 19 | # Implementation for tests 20 | class MockClient(IClient): 21 | def __init__(self, return_data: str) -> None: 22 | self._return_data = return_data 23 | 24 | def request(self, url: str) -> str: 25 | # Mocked behavior for testing 26 | return f'{self._return_data} from "{url}"' 27 | 28 | 29 | class Service: 30 | # Accepts any IClient implementation 31 | def __init__(self, client: IClient) -> None: 32 | self._client = client 33 | 34 | def do_something(self) -> str: 35 | return self._client.request('https://example.com') 36 | 37 | 38 | # Usage in regular code 39 | real_client = RealClient() 40 | service = Service(real_client) 41 | print(service.do_something()) # Output: "https://example.com" call result 42 | 43 | # Usage in tests 44 | mocked_client = MockClient('mocked data') 45 | service = Service(mocked_client) 46 | print(service.do_something()) # Output: mocked data from "https://example.com" 47 | -------------------------------------------------------------------------------- /docs/code/providers/scopes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/docs/code/providers/scopes/__init__.py -------------------------------------------------------------------------------- /docs/code/providers/scopes/contextual.py: -------------------------------------------------------------------------------- 1 | from waku import WakuFactory, module 2 | from waku.di import contextual, Scope 3 | 4 | some_object = (1, 2, 3) 5 | 6 | 7 | @module( 8 | providers=[ 9 | contextual(provided_type=tuple, scope=Scope.REQUEST), 10 | ], 11 | ) 12 | class AppModule: 13 | pass 14 | 15 | 16 | async def main() -> None: 17 | application = WakuFactory(AppModule).create() 18 | async with ( 19 | application, 20 | application.container( 21 | context={tuple: some_object}, 22 | ) as request_container, 23 | ): 24 | obj = await request_container.get(tuple) 25 | assert obj is some_object 26 | 27 | # Providers are not disposed at this point automatically 28 | # because they are not part of the application container lifecycle 29 | -------------------------------------------------------------------------------- /docs/code/providers/scopes/contextual_real.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from waku import WakuFactory, module 3 | from waku.di import contextual, scoped, Scope 4 | 5 | 6 | class UserService: 7 | """Service that uses the current HTTP request for user-specific operations.""" 8 | 9 | def __init__(self, request: Request) -> None: 10 | self.request = request 11 | 12 | def get_user_info(self) -> dict[str, str]: 13 | """Extract user information from the request headers.""" 14 | return { 15 | 'user_id': self.request.headers.get('user-id', 'anonymous'), 16 | 'session_id': self.request.headers.get('session-id', 'none'), 17 | 'user_agent': self.request.headers.get('user-agent', 'unknown'), 18 | } 19 | 20 | 21 | @module( 22 | providers=[ 23 | contextual(provided_type=Request, scope=Scope.REQUEST), 24 | scoped(UserService), 25 | ], 26 | ) 27 | class WebModule: 28 | pass 29 | 30 | 31 | # FastAPI application setup 32 | app = FastAPI() 33 | application = WakuFactory(WebModule).create() 34 | 35 | 36 | @app.get('/user-info') 37 | async def get_user_info(request: Request) -> dict[str, str]: 38 | """Endpoint that uses contextual dependency injection.""" 39 | async with ( 40 | application, 41 | application.container( 42 | context={Request: request}, 43 | ) as request_container, 44 | ): 45 | # UserService automatically receives the current HTTP request 46 | user_service = await request_container.get(UserService) 47 | return user_service.get_user_info() 48 | 49 | 50 | # Example usage: 51 | # curl -H "user-id: john123" -H "session-id: abc456" http://localhost:8000/user-info 52 | -------------------------------------------------------------------------------- /docs/code/providers/scopes/object.py: -------------------------------------------------------------------------------- 1 | from waku import WakuFactory, module 2 | from waku.di import object_ 3 | 4 | some_object = (1, 2, 3) 5 | 6 | 7 | @module( 8 | providers=[ 9 | object_(some_object, provided_type=tuple), 10 | ], 11 | ) 12 | class AppModule: 13 | pass 14 | 15 | 16 | async def main() -> None: 17 | application = WakuFactory(AppModule).create() 18 | async with application, application.container() as request_container: 19 | obj = await request_container.get(tuple) 20 | assert obj is some_object 21 | 22 | # Providers are not disposed at this point automatically 23 | # because they are not part of the application container lifecycle 24 | -------------------------------------------------------------------------------- /docs/code/providers/scopes/scoped.py: -------------------------------------------------------------------------------- 1 | from waku import WakuFactory, module 2 | from waku.di import scoped 3 | 4 | 5 | @module(providers=[scoped(list)]) 6 | class AppModule: 7 | pass 8 | 9 | 10 | async def main() -> None: 11 | application = WakuFactory(AppModule).create() 12 | async with application: 13 | async with application.container() as request_container: 14 | obj_1 = await request_container.get(list) 15 | obj_2 = await request_container.get(list) 16 | assert obj_1 is obj_2 17 | 18 | # Providers are disposed at this point 19 | -------------------------------------------------------------------------------- /docs/code/providers/scopes/singleton.py: -------------------------------------------------------------------------------- 1 | from waku import WakuFactory, module 2 | from waku.di import singleton 3 | 4 | 5 | @module(providers=[singleton(list)]) 6 | class AppModule: 7 | pass 8 | 9 | 10 | async def main() -> None: 11 | application = WakuFactory(AppModule).create() 12 | async with application: 13 | async with application.container() as request_container: 14 | obj_1 = await request_container.get(list) 15 | 16 | async with application.container(): 17 | obj_2 = await request_container.get(list) 18 | 19 | assert obj_1 is obj_2 20 | 21 | # Providers are disposed at this point 22 | -------------------------------------------------------------------------------- /docs/code/providers/scopes/transient.py: -------------------------------------------------------------------------------- 1 | from waku import WakuFactory, module 2 | from waku.di import transient 3 | 4 | 5 | @module(providers=[transient(list)]) 6 | class AppModule: 7 | pass 8 | 9 | 10 | async def main() -> None: 11 | application = WakuFactory(AppModule).create() 12 | async with application: 13 | async with application.container() as request_container: 14 | obj_1 = await request_container.get(list) 15 | obj_2 = await request_container.get(list) 16 | assert obj_1 is not obj_2 17 | 18 | # Providers are disposed at this point 19 | -------------------------------------------------------------------------------- /docs/contributing/docs.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## How to help 4 | 5 | You will be of invaluable help if you contribute to the documentation. 6 | 7 | Such a contribution can be: 8 | 9 | - Indications of inaccuracies, errors, typos 10 | - Suggestions for editing specific sections 11 | - Making additions 12 | 13 | You can report all this in [discussions](https://github.com/waku-py/waku/discussions) on GitHub or by opening 14 | an [issue](https://github.com/waku-py/waku/issues). 15 | 16 | ## How to get started 17 | 18 | 1. Follow the steps for development setup in the [contributing guide](index.md#development-setup) 19 | 2. Start the local documentation server for live preview of changes 20 | 21 | ```shell 22 | mkdocs serve 23 | ``` 24 | 25 | 3. Go to the `docs/` directory and make your changes 26 | 27 | After making all the changes, you can issue a `PR` with them - and we will gladly accept it! 28 | -------------------------------------------------------------------------------- /docs/contributing/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Contributing 3 | --- 4 | 5 | --8<-- "CONTRIBUTING.md:1:5,22" 6 | -------------------------------------------------------------------------------- /docs/examples/cqrs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mediator (CQRS) 3 | hide: 4 | - toc 5 | --- 6 | 7 | Based on the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern, the mediator is used to decouple the command and query logic from the domain model. 8 | 9 | Implementation heavily inspired by C# [MediatR](https://github.com/jbogard/MediatR) library. 10 | 11 | For full documentation, visit the [Mediator (CQRS)](../usage/cqrs.md) section. 12 | 13 | ## Code 14 | -------------------------------------------------------------------------------- /docs/examples/modularity.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Modularity 3 | hide: 4 | - toc 5 | --- 6 | 7 | Example of how to use modules, dynamic modules and linking them together to build an application. 8 | 9 | For full documentation on modules, visit the [Modules](../usage/modules.md) section. 10 | 11 | ## Code 12 | -------------------------------------------------------------------------------- /docs/includes/abbreviations.md: -------------------------------------------------------------------------------- 1 | *[DI]: Dependency Injection 2 | *[IoC]: Inversion of Control 3 | *[CQRS]: Command and Query Responsibility Segregation 4 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Overview 3 | hide: 4 | - navigation 5 | --- 6 | 7 | --8<-- "README.md" 8 | -------------------------------------------------------------------------------- /docs/integrations/asgi.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: ASGI Integration 3 | hide: 4 | - toc 5 | --- 6 | 7 | # ASGI Integration 8 | 9 | `waku` can be seamlessly integrated into any **ASGI** application. To achieve this, set up `waku` as you normally would, 10 | then add the `ApplicationMiddleware` to your ASGI application’s middleware stack. 11 | 12 | ## Example with FastAPI 13 | 14 | ```python linenums="1" 15 | from fastapi import FastAPI 16 | from fastapi.middleware import Middleware 17 | from waku import WakuApplication 18 | from waku.contrib.asgi import WakuMiddleware 19 | 20 | 21 | def bootstrap_application() -> WakuApplication: 22 | # Replace with your actual waku app setup (e.g., ApplicationFactory.create) 23 | ... 24 | 25 | 26 | # Create the waku application 27 | application = bootstrap_application() 28 | 29 | # Create the FastAPI app with the waku middleware 30 | app = FastAPI( 31 | middleware=[ 32 | Middleware(WakuMiddleware, application=application), 33 | ], 34 | ) 35 | 36 | ``` 37 | 38 | In this example, the `ApplicationMiddleware` bridges `waku` with FastAPI, allowing dependency injection and module 39 | management within your ASGI routes. 40 | -------------------------------------------------------------------------------- /docs/integrations/index.md: -------------------------------------------------------------------------------- 1 | # Integrations 2 | -------------------------------------------------------------------------------- /docs/integrations/litestar.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Litestar Integration 3 | hide: 4 | - toc 5 | --- 6 | 7 | # Litestar Integration 8 | 9 | `waku` can be seamlessly integrated with **Litestar** using the `ApplicationPlugin`. To do this, set up `waku` as usual 10 | and then include the plugin in your Litestar application configuration. 11 | 12 | ## Example 13 | 14 | Here’s how to integrate `waku` with a Litestar application: 15 | 16 | ```python linenums="1" 17 | from litestar import Litestar 18 | from waku import WakuApplication 19 | from waku.contrib.litestar import WakuPlugin 20 | 21 | 22 | def bootstrap_application() -> WakuApplication: 23 | # Replace with your actual waku app setup (e.g., ApplicationFactory.create) 24 | ... 25 | 26 | 27 | # Create the waku application 28 | application = bootstrap_application() 29 | 30 | # Create the Litestar app with the waku plugin 31 | app = Litestar(plugins=[WakuPlugin(application)]) 32 | 33 | ``` 34 | 35 | In this example, the `ApplicationPlugin` enables `waku` dependency injection and module system within your Litestar 36 | application. 37 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API Reference 3 | hide: 4 | - feedback 5 | - navigation 6 | --- 7 | 8 | # ::: waku 9 | options: 10 | show_submodules: true 11 | -------------------------------------------------------------------------------- /docs/usage/cqrs.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Mediator (CQRS) 3 | --- 4 | -------------------------------------------------------------------------------- /docs/usage/extensions/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Lifecycle hooks 3 | --- 4 | -------------------------------------------------------------------------------- /docs/usage/extensions/validation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Validation 3 | --- 4 | -------------------------------------------------------------------------------- /docs/usage/lifespan.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Lifespan 3 | --- 4 | 5 | # Lifespan 6 | -------------------------------------------------------------------------------- /docs/usage/modules.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Modules 3 | --- 4 | 5 | `waku` modularity system is heavily inspired by the [NestJS](https://github.com/nestjs/nest) 6 | and [Tramvai](https://tramvai.dev) frameworks. 7 | 8 | The concept of modularity is well-explained with examples in 9 | the [NestJS documentation](https://docs.nestjs.com/modules). 10 | 11 | ## Module 12 | 13 | A module is a class annotated with the `@module()` decorator. This decorator attaches metadata to the class, 14 | which `waku` uses to construct the application graph. 15 | 16 | Every `waku` application has at least one module: the root module, also known as the composition root. 17 | This module serves as the starting point for `waku` to build the entire application graph. 18 | 19 | | Parameter | Description | 20 | |--------------|:-------------------------------------------------| 21 | | `providers` | List of providers for dependency injection | 22 | | `imports` | List of modules imported by this module | 23 | | `exports` | List of types or modules exported by this module | 24 | | `extensions` | List of module extensions for lifecycle hooks | 25 | | `is_global` | Whether this module is global or not | 26 | 27 | The module encapsulates providers by default, meaning you can only inject providers that are either part of the current 28 | module or explicitly exported from other imported modules. The exported providers from a module essentially serve as the 29 | module's public interface or API. 30 | 31 | ```python hl_lines="11-15" linenums="1" 32 | from waku import module 33 | from waku.di import Scoped 34 | 35 | from app.modules.config.module import ConfigModule 36 | 37 | 38 | class UsersService: 39 | pass 40 | 41 | 42 | @module( 43 | providers=[Scoped(UsersService)], # Register the service with a scoped lifetime 44 | imports=[ConfigModule], # Import another module 45 | exports=[UsersService], # Expose the service to other modules 46 | ) 47 | class UsersModule: 48 | pass 49 | 50 | 51 | @module(imports=[UsersModule]) # Root module importing UsersModule 52 | class AppModule: 53 | pass 54 | 55 | ``` 56 | 57 | !!! note 58 | Encapsulation is enforced by [validators](extensions/validation.md), which you can disable at runtime if needed. 59 | However, **disabling them entirely is not recommended**, as they help maintain modularity. 60 | 61 | ## Module Re-exporting 62 | 63 | You can re-export a module by including it in the `exports` list of another module. 64 | This is useful for exposing a module’s providers to other modules that import the re-exporting module. 65 | 66 | ```python hl_lines="3" linenums="1" 67 | @module( 68 | imports=[UsersModule], 69 | exports=[UsersModule], 70 | ) 71 | class IAMModule: 72 | pass 73 | 74 | ``` 75 | 76 | ## Global modules 77 | 78 | If you need to import the same set of modules across your application, you can mark a module as global. 79 | Once a module is global, its providers can be injected anywhere in the application without requiring explicit imports in 80 | every module. 81 | 82 | To make a module global, set the `is_global` param to `True` in the `@module()` decorator. 83 | 84 | !!! note 85 | Root module are always global. 86 | 87 | !!! warning 88 | Global modules are not recommended for large applications, 89 | as they can lead to tight coupling and make the application harder to maintain. 90 | 91 | ```python hl_lines="4" linenums="1" 92 | from waku import module 93 | 94 | 95 | @module(is_global=True) 96 | class UsersModule: 97 | pass 98 | 99 | ``` 100 | 101 | ## Dynamic Module 102 | 103 | Dynamic modules allow you to create modules dynamically based on conditions, 104 | such as the runtime environment of your application. 105 | 106 | ```python hl_lines="23-26" linenums="1" 107 | from waku import DynamicModule, module 108 | from waku.di import Scoped 109 | 110 | 111 | class ConfigService: 112 | pass 113 | 114 | 115 | class DevConfigService(ConfigService): 116 | pass 117 | 118 | 119 | class DefaultConfigService(ConfigService): 120 | pass 121 | 122 | 123 | @module() 124 | class ConfigModule: 125 | @classmethod 126 | def register(cls, env: str = 'dev') -> DynamicModule: 127 | # Choose the config provider based on the environment 128 | config_provider = DevConfigService if env == 'dev' else DefaultConfigService 129 | return DynamicModule( 130 | parent_module=cls, 131 | providers=[Scoped(config_provider, type_=ConfigService)], # Register with interface type 132 | ) 133 | 134 | ``` 135 | 136 | And then you can use it in any of your modules or in the root module: 137 | 138 | ```python hl_lines="8" linenums="1" 139 | from waku import module 140 | 141 | from app.modules.config.module import ConfigModule 142 | 143 | 144 | @module( 145 | imports=[ 146 | ConfigModule.register(env='dev'), 147 | ], 148 | ) 149 | class AppModule: 150 | pass 151 | 152 | ``` 153 | 154 | You can also make a [dynamic module](#dynamic-module) global by setting `is_global=True` in the `DynamicModule` 155 | constructor. 156 | 157 | !!! note 158 | While you can use any method name instead of `register`, we recommend sticking with `register` for consistency. 159 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/examples/__init__.py -------------------------------------------------------------------------------- /examples/contextual_provider.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from waku import WakuFactory, module 4 | from waku.di import Scope, contextual, scoped 5 | 6 | 7 | @dataclass 8 | class RequestContext: 9 | """Represents external request data passed from a web framework.""" 10 | 11 | user_id: str 12 | session_id: str 13 | request_path: str 14 | 15 | 16 | class LoggingService: 17 | """Service that uses contextual data for request-specific logging.""" 18 | 19 | def __init__(self, request_ctx: RequestContext) -> None: 20 | self.request_ctx = request_ctx 21 | 22 | def log_action(self, action: str) -> str: 23 | return f'User {self.request_ctx.user_id} performed {action} on {self.request_ctx.request_path}' 24 | 25 | 26 | @module( 27 | providers=[ 28 | contextual(provided_type=RequestContext, scope=Scope.REQUEST), 29 | scoped(LoggingService), 30 | ], 31 | ) 32 | class AppModule: 33 | pass 34 | 35 | 36 | async def main() -> None: 37 | # Simulate external request data from a web framework 38 | request_data = RequestContext(user_id='user123', session_id='session456', request_path='/api/users') 39 | 40 | application = WakuFactory(AppModule).create() 41 | async with ( 42 | application, 43 | application.container( 44 | context={RequestContext: request_data}, 45 | ) as request_container, 46 | ): 47 | # LoggingService receives the contextual RequestContext automatically 48 | logging_service = await request_container.get(LoggingService) 49 | message = logging_service.log_action('update profile') 50 | print(message) # "User user123 performed update profile on /api/users" 51 | 52 | 53 | if __name__ == '__main__': 54 | import asyncio 55 | 56 | asyncio.run(main()) 57 | -------------------------------------------------------------------------------- /examples/cqrs/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/examples/cqrs/__init__.py -------------------------------------------------------------------------------- /examples/cqrs/basic_usage.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating basic CQRS usage with the Mediator extension in Waku. 2 | 3 | This example shows: 4 | 1. How to define CQRS commands, events, and handlers 5 | 2. How to register handlers with the Mediator extension 6 | 3. How to compose modules and application 7 | 4. How to create and use the application to send a command 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import asyncio 13 | import logging 14 | import uuid 15 | from contextlib import asynccontextmanager 16 | from dataclasses import dataclass 17 | from typing import TYPE_CHECKING, ParamSpec, TypeVar 18 | 19 | from waku import WakuApplication, WakuFactory, module 20 | from waku.cqrs import ( 21 | Event, 22 | EventHandler, 23 | IMediator, 24 | MediatorExtension, 25 | MediatorModule, 26 | Request, 27 | RequestHandler, 28 | Response, 29 | ) 30 | 31 | if TYPE_CHECKING: 32 | from collections.abc import AsyncIterator 33 | 34 | P = ParamSpec('P') 35 | T = TypeVar('T') 36 | 37 | logging.basicConfig(level=logging.INFO) 38 | logger = logging.getLogger(__name__) 39 | 40 | 41 | @dataclass(frozen=True, kw_only=True) 42 | class CreateMeetingResult(Response): 43 | """Result of creating a meeting.""" 44 | 45 | meeting_id: uuid.UUID 46 | 47 | 48 | @dataclass(frozen=True, kw_only=True) 49 | class CreateMeetingCommand(Request[CreateMeetingResult]): 50 | """Command to create a new meeting.""" 51 | 52 | user_id: uuid.UUID 53 | 54 | 55 | @dataclass(frozen=True, kw_only=True) 56 | class MeetingCreatedEvent(Event): 57 | """Event triggered when a meeting is created.""" 58 | 59 | user_id: uuid.UUID 60 | meeting_id: uuid.UUID 61 | 62 | 63 | class CreatingMeetingCommandHandler(RequestHandler[CreateMeetingCommand, CreateMeetingResult]): 64 | """Handles CreateMeetingCommand by creating a meeting and publishing an event.""" 65 | 66 | def __init__(self, mediator: IMediator) -> None: 67 | self._mediator = mediator 68 | 69 | async def handle(self, request: CreateMeetingCommand) -> CreateMeetingResult: 70 | """Handle the creation of a meeting. 71 | 72 | Args: 73 | request: The command containing user_id. 74 | 75 | Returns: 76 | CreateMeetingResult: The result containing the new meeting_id. 77 | """ 78 | meeting_id = uuid.uuid4() 79 | logger.info('new meeting created user_id=%s', request.user_id) 80 | await self._mediator.publish(MeetingCreatedEvent(user_id=request.user_id, meeting_id=meeting_id)) 81 | return CreateMeetingResult(meeting_id=meeting_id) 82 | 83 | 84 | class MeetingCreatedEventHandler(EventHandler[MeetingCreatedEvent]): 85 | """Handles MeetingCreatedEvent by logging the event.""" 86 | 87 | async def handle(self, event: MeetingCreatedEvent) -> None: 88 | """Handle the meeting created event. 89 | 90 | Args: 91 | event: The event containing user_id and meeting_id. 92 | """ 93 | logger.info('meeting created event handled user_id=%s', event.user_id) 94 | 95 | 96 | @asynccontextmanager 97 | async def lifespan(_: WakuApplication) -> AsyncIterator[None]: 98 | """Application lifespan context manager for startup and shutdown logging.""" 99 | logger.info('Lifespan startup') 100 | yield 101 | logger.info('Lifespan shutdown') 102 | 103 | 104 | @module( 105 | extensions=[ 106 | ( 107 | MediatorExtension() 108 | .bind_request(CreateMeetingCommand, CreatingMeetingCommandHandler) 109 | .bind_event(MeetingCreatedEvent, [MeetingCreatedEventHandler]) 110 | ), 111 | ], 112 | ) 113 | class SomeModule: 114 | """Module registering meeting command and event handlers.""" 115 | 116 | 117 | @module( 118 | imports=[ 119 | SomeModule, 120 | MediatorModule.register(), 121 | ], 122 | ) 123 | class AppModule: 124 | """Root application module importing all submodules.""" 125 | 126 | 127 | async def main() -> None: 128 | """Main function to demonstrate CQRS usage with meeting creation.""" 129 | app = WakuFactory(AppModule).create() 130 | 131 | async with app, app.container() as container: 132 | mediator = await container.get(IMediator) 133 | 134 | command = CreateMeetingCommand(user_id=uuid.uuid4()) 135 | await mediator.send(command) 136 | 137 | 138 | if __name__ == '__main__': 139 | asyncio.run(main()) 140 | -------------------------------------------------------------------------------- /examples/modularity.py: -------------------------------------------------------------------------------- 1 | """Example demonstrating modularity and dependency injection with Waku. 2 | 3 | This example shows: 4 | 1. How to define providers and modules 5 | 2. How to compose modules for different application layers 6 | 3. How to use dependency injection in entrypoints 7 | 4. How to bootstrap and run the application 8 | """ 9 | 10 | from __future__ import annotations 11 | 12 | import asyncio 13 | import logging 14 | from contextlib import asynccontextmanager 15 | from typing import TYPE_CHECKING, ParamSpec, TypeVar 16 | 17 | from dishka.integrations.base import wrap_injection 18 | 19 | from waku import DynamicModule, WakuApplication, module 20 | from waku.di import AsyncContainer, Injected, scoped, singleton 21 | from waku.factory import WakuFactory 22 | 23 | if TYPE_CHECKING: 24 | from collections.abc import AsyncIterator, Callable 25 | 26 | P = ParamSpec('P') 27 | T = TypeVar('T') 28 | 29 | logging.basicConfig(level=logging.INFO) 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class ConfigService: 34 | """Service for configuration retrieval.""" 35 | 36 | def get(self, option: str) -> str: 37 | """Get a configuration option value. 38 | 39 | Args: 40 | option: The configuration option name. 41 | 42 | Returns: 43 | str: The value of the configuration option. 44 | """ 45 | return option 46 | 47 | 48 | @module() 49 | class ConfigModule: 50 | """Module providing ConfigService.""" 51 | 52 | @classmethod 53 | def register(cls, env: str = 'dev') -> DynamicModule: 54 | """Register the config module for a specific environment. 55 | 56 | Args: 57 | env: The environment name (default: 'dev'). 58 | 59 | Returns: 60 | DynamicModule: The configured dynamic module. 61 | """ 62 | logger.info('Loading config for env=%s', env) 63 | return DynamicModule( 64 | parent_module=cls, 65 | providers=[singleton(ConfigService)], 66 | exports=[ConfigService], 67 | ) 68 | 69 | 70 | class UserService: 71 | """Service for user-related operations.""" 72 | 73 | async def great(self, name: str) -> str: 74 | """Greet a user by name. 75 | 76 | Args: 77 | name: The user's name. 78 | 79 | Returns: 80 | str: The greeting message. 81 | """ 82 | return f'Hello, {name}!' 83 | 84 | 85 | @module( 86 | providers=[scoped(UserService)], 87 | exports=[UserService], 88 | ) 89 | class UserModule: 90 | """Module providing UserService.""" 91 | 92 | 93 | @module(imports=[UserModule]) 94 | class IAMModule: 95 | """Module for IAM-related functionality.""" 96 | 97 | 98 | @module(imports=[UserModule, IAMModule]) 99 | class AdminModule: 100 | """Module for admin-related functionality.""" 101 | 102 | 103 | @module( 104 | imports=[ 105 | AdminModule, 106 | ConfigModule.register(env='prod'), 107 | ], 108 | exports=[ConfigModule], 109 | ) 110 | class AppModule: 111 | """Root application module importing all submodules.""" 112 | 113 | 114 | def _inject(func: Callable[P, T]) -> Callable[P, T]: 115 | """Simple inject decorator for example purposes. 116 | 117 | Args: 118 | func: The function to wrap for injection. 119 | 120 | Returns: 121 | Callable[P, T]: The wrapped function. 122 | """ 123 | return wrap_injection( 124 | func=func, 125 | is_async=True, 126 | container_getter=lambda args, _: args[0], 127 | ) 128 | 129 | 130 | @_inject 131 | async def handler( 132 | container: AsyncContainer, 133 | user_service: Injected[UserService], 134 | config_service: Injected[ConfigService], 135 | ) -> None: 136 | """Example handler function using injected services. 137 | 138 | Args: 139 | container: The async DI container. 140 | user_service: The injected UserService. 141 | config_service: The injected ConfigService. 142 | """ 143 | 144 | 145 | @asynccontextmanager 146 | async def lifespan(_: WakuApplication) -> AsyncIterator[None]: 147 | """Application lifespan context manager for startup and shutdown logging.""" 148 | logger.info('Lifespan startup') 149 | yield 150 | logger.info('Lifespan shutdown') 151 | 152 | 153 | def bootstrap() -> WakuApplication: 154 | """Create the Waku application via factory. 155 | 156 | Returns: 157 | WakuApplication: The created application instance. 158 | """ 159 | return WakuFactory(AppModule, lifespan=[lifespan]).create() 160 | 161 | 162 | async def main() -> None: 163 | """Main function to run the application and handler.""" 164 | app = bootstrap() 165 | async with app, app.container() as request_container: 166 | await handler(request_container) # type: ignore[call-arg] 167 | 168 | 169 | if __name__ == '__main__': 170 | asyncio.run(main()) 171 | -------------------------------------------------------------------------------- /gitlint_plugins.py: -------------------------------------------------------------------------------- 1 | """This module provides custom gitlint rules to enforce Conventional Commits format. 2 | 3 | See https://www.conventionalcommits.org/en/v1.0.0/. 4 | """ 5 | 6 | import re 7 | import tomllib 8 | from pathlib import Path 9 | from typing import ClassVar, Final 10 | 11 | from gitlint.git import GitCommit 12 | from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation 13 | 14 | BASE_DIR: Final = Path(__file__).parent 15 | 16 | COMMIT_PATTERN: Final = r'^(?P[^(]+?)(\((?P[^)]+?)\))?!?:\s.+$' 17 | RULE_REGEX: Final = re.compile(COMMIT_PATTERN) 18 | 19 | CONVENTIONAL_COMMIT_ERROR: Final = ( 20 | "Commit title does not follow ConventionalCommits.org format 'type(optional-scope): description'" 21 | ) 22 | 23 | 24 | class ConventionalCommitTitle(LineRule): # type: ignore[misc] 25 | name = 'conventional-commit-title' 26 | id = 'CT1' 27 | target = CommitMessageTitle 28 | 29 | contexts: ClassVar[tuple[str, ...]] = ( 30 | 'core', 31 | 'deps', 32 | 'di', 33 | 'ext', 34 | 'infra', 35 | 'linters', 36 | 'cqrs', 37 | 'release', 38 | 'tests', 39 | 'validation', 40 | ) 41 | default_types: ClassVar[tuple[str, ...]] = ( 42 | 'build', 43 | 'chore', 44 | 'ci', 45 | 'docs', 46 | 'feat', 47 | 'fix', 48 | 'perf', 49 | 'refactor', 50 | # 'revert', # currently unsupported in python-semantic-release 51 | 'style', 52 | 'test', 53 | ) 54 | 55 | def validate(self, line: str, _: GitCommit) -> list[RuleViolation]: 56 | """Validate a commit message line. 57 | 58 | Args: 59 | line: The commit message line to validate. 60 | _: The git commit object (unused). 61 | 62 | Returns: 63 | List of rule violations found in the commit message. 64 | """ 65 | if self._is_special_commit(line): 66 | return [] 67 | 68 | match = RULE_REGEX.match(line) 69 | if not match: 70 | return [RuleViolation(self.id, CONVENTIONAL_COMMIT_ERROR, line)] 71 | 72 | return self._validate_match(match, line) 73 | 74 | def _is_special_commit(self, line: str) -> bool: # noqa: PLR6301 75 | """Check if the commit is a special type that should be ignored. 76 | 77 | Args: 78 | line: The commit message line. 79 | 80 | Returns: 81 | True if the commit should be ignored, False otherwise. 82 | """ 83 | return line.startswith(('Draft:', 'WIP:', 'Merge')) 84 | 85 | def _validate_match(self, match: re.Match[str], line: str) -> list[RuleViolation]: 86 | """Validate the components of a matched commit message. 87 | 88 | Args: 89 | match: The regex match object containing commit components. 90 | line: The original commit message line. 91 | 92 | Returns: 93 | List of rule violations found in the commit components. 94 | """ 95 | violations: list[RuleViolation] = [] 96 | 97 | type_ = match.group('type') 98 | context = match.group('context') 99 | 100 | violations.extend(self._validate_type(type_, line)) 101 | if context: 102 | violations.extend(self._validate_context(context, line)) 103 | 104 | return violations 105 | 106 | def _validate_type(self, type_: str, line: str) -> list[RuleViolation]: 107 | """Validate the commit type. 108 | 109 | Args: 110 | type_: The commit type to validate. 111 | line: The original commit message line. 112 | 113 | Returns: 114 | List of violations if the type is invalid. 115 | """ 116 | allowed_types = self._get_types() 117 | if type_ not in allowed_types: 118 | opt_str = ', '.join(sorted(allowed_types)) 119 | return [RuleViolation(self.id, f'Commit type {type_} is not one of {opt_str}', line)] 120 | return [] 121 | 122 | def _validate_context(self, context: str, line: str) -> list[RuleViolation]: 123 | """Validate the commit context. 124 | 125 | Args: 126 | context: The commit context to validate. 127 | line: The original commit message line. 128 | 129 | Returns: 130 | List of violations if the context is invalid. 131 | """ 132 | allowed_contexts = set(self.contexts) 133 | if context not in allowed_contexts: 134 | opt_str = ', '.join(sorted(allowed_contexts)) 135 | return [RuleViolation(self.id, f'Commit context is not one of {opt_str}', line)] 136 | return [] 137 | 138 | def _get_types(self) -> set[str]: 139 | """Get allowed commit types from pyproject.toml or defaults. 140 | 141 | Returns: 142 | Set of allowed commit types. 143 | """ 144 | try: 145 | pyproject = tomllib.loads(BASE_DIR.joinpath('pyproject.toml').read_text()) 146 | allowed_types = pyproject['tool']['semantic_release']['commit_parser_options']['allowed_tags'] 147 | except (KeyError, tomllib.TOMLDecodeError): 148 | self.log.exception('Failed to load commit types from pyproject.toml') 149 | allowed_types = self.default_types 150 | 151 | return set(allowed_types) 152 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://squidfunk.github.io/mkdocs-material/schema.json 2 | site_name: waku 3 | site_description: A Python framework for creating modular, loosely coupled, and extensible applications 4 | site_url: https://waku-py.github.io/waku/ 5 | site_dir: site 6 | strict: true 7 | copyright: '© 2024-2025 waku-py' 8 | 9 | # Repository 10 | repo_name: waku-py/waku 11 | repo_url: https://github.com/waku-py/waku 12 | 13 | extra: 14 | analytics: 15 | provider: google 16 | property: G-58BG5HHK50 17 | feedback: 18 | title: Was this page helpful? 19 | ratings: 20 | - icon: material/emoticon-happy-outline 21 | name: This page was helpful 22 | data: 1 23 | note: >- 24 | Thanks for your feedback! 25 | - icon: material/emoticon-sad-outline 26 | name: This page could be improved 27 | data: 0 28 | note: >- 29 | Thanks for your feedback! Help us improve this page by 30 | using our feedback form. 31 | social: 32 | - icon: fontawesome/brands/github 33 | link: https://github.com/waku-py/waku 34 | - icon: fontawesome/brands/python 35 | link: https://pypi.org/project/waku/ 36 | 37 | 38 | # Configuration 39 | watch: [ mkdocs.yml, README.md, CONTRIBUTING.md, CHANGELOG.md, src/waku, examples ] 40 | 41 | # https://www.mkdocs.org/user-guide/configuration/#validation 42 | validation: 43 | omitted_files: warn 44 | absolute_links: warn 45 | unrecognized_links: warn 46 | links: 47 | absolute_links: relative_to_docs 48 | 49 | theme: 50 | name: material 51 | features: 52 | - content.code.annotate 53 | - content.code.copy 54 | - content.code.select 55 | - content.tooltips 56 | - navigation.footer 57 | - navigation.indexes 58 | - navigation.instant 59 | - navigation.instant.progress 60 | - navigation.tabs 61 | - navigation.tabs.sticky 62 | - navigation.top 63 | - navigation.tracking 64 | - optimize 65 | - search.highlight 66 | - search.suggest 67 | - toc.follow 68 | 69 | palette: 70 | - media: "(prefers-color-scheme)" 71 | scheme: auto 72 | toggle: 73 | icon: material/brightness-auto 74 | name: Switch to Light Mode 75 | 76 | - media: "(prefers-color-scheme: light)" 77 | scheme: default 78 | primary: deep purple 79 | accent: cyan 80 | toggle: 81 | icon: material/brightness-4 82 | name: Switch to Dark Mode 83 | 84 | - media: "(prefers-color-scheme: dark)" 85 | scheme: slate 86 | primary: deep purple 87 | accent: cyan 88 | toggle: 89 | icon: material/brightness-7 90 | name: Switch to System Preference 91 | 92 | font: 93 | text: Roboto 94 | code: JetBrains Mono 95 | 96 | icon: 97 | repo: fontawesome/brands/github 98 | 99 | # Plugins 100 | plugins: 101 | - search 102 | - exclude: 103 | glob: 104 | - "includes/*" 105 | - "__pycache__/*" 106 | - "*.pyc" 107 | - "*.pyo" 108 | - ".git/*" 109 | - git-revision-date-localized: 110 | enabled: !ENV [ DOCS_DEPLOY, false ] 111 | type: timeago 112 | enable_creation_date: true 113 | fallback_to_build_date: true 114 | - mkdocstrings: 115 | handlers: 116 | python: 117 | inventories: 118 | - url: https://docs.python.org/3/objects.inv 119 | domains: [ py, std ] 120 | paths: [ src ] 121 | options: 122 | docstring_style: google 123 | filters: 124 | - '!^_' 125 | show_signature_annotations: true 126 | show_root_heading: true 127 | show_source: true 128 | show_if_no_docstring: true 129 | inherited_members: true 130 | members_order: source 131 | separate_signature: true 132 | unwrap_annotated: true 133 | merge_init_into_class: true 134 | docstring_section_style: spacy 135 | show_docstring_examples: true 136 | signature_crossrefs: true 137 | show_symbol_type_heading: true 138 | show_symbol_type_toc: true 139 | 140 | 141 | # Extensions 142 | markdown_extensions: 143 | - abbr 144 | - admonition # !!! blocks support 145 | - attr_list # specify html attrs in markdown 146 | - md_in_html # render md wrapped to html tags 147 | - tables 148 | - toc: 149 | permalink: true 150 | title: Table of contents 151 | slugify: !!python/object/apply:pymdownx.slugs.slugify { kwds: { case: lower } } 152 | - pymdownx.details # admonition collapsible 153 | - pymdownx.emoji: # render material icons 154 | emoji_index: !!python/name:material.extensions.emoji.twemoji 155 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 156 | - pymdownx.highlight: 157 | anchor_linenums: true # allows link to codeline 158 | line_spans: __span 159 | pygments_lang_class: true 160 | - pymdownx.inlinehilite # inline code highlighting `#!python ` 161 | - pymdownx.magiclink 162 | - pymdownx.snippets: 163 | base_path: [ !relative $config_dir ] 164 | check_paths: true 165 | auto_append: 166 | - docs/includes/abbreviations.md 167 | - pymdownx.superfences: # highlight code syntax 168 | custom_fences: 169 | - name: mermaid 170 | class: mermaid 171 | format: !!python/name:pymdownx.superfences.fence_code_format 172 | - pymdownx.tabbed: 173 | alternate_style: true # create tabs group 174 | slugify: !!python/object/apply:pymdownx.slugs.slugify { kwds: { case: lower } } 175 | - pymdownx.tasklist: # create task lists with `- [ ]` 176 | custom_checkbox: true 177 | 178 | # Page tree 179 | # TODO: move to literate-nav plugin 180 | nav: 181 | - Overview: index.md 182 | - Getting Started: getting-started.md 183 | - Usage: 184 | - usage/providers.md 185 | - usage/modules.md 186 | - usage/lifespan.md 187 | - usage/cqrs.md 188 | - Extensions: 189 | - usage/extensions/index.md 190 | - Validation: usage/extensions/validation.md 191 | - Integrations: 192 | - integrations/index.md 193 | - ASGI: integrations/asgi.md 194 | - Litestar: integrations/litestar.md 195 | - Examples: 196 | - Modularity: examples/modularity.md 197 | - CQRS: examples/cqrs.md 198 | - API Reference: reference.md 199 | - Contributing: 200 | - contributing/index.md 201 | - Documentation: contributing/docs.md 202 | - Changelog: changelog.md 203 | -------------------------------------------------------------------------------- /pyrightconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src" 4 | ], 5 | "exclude": [ 6 | "**/__pycache__", 7 | "tests" 8 | ], 9 | "pythonVersion": "3.11", 10 | "pythonPlatform": "All", 11 | "typeCheckingMode": "strict", 12 | "useLibraryCodeForTypes": true, 13 | "reportMissingImports": true, 14 | "reportMissingTypeStubs": true, 15 | "executionEnvironments": [ 16 | { 17 | "root": "src" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/waku/__init__.py: -------------------------------------------------------------------------------- 1 | from waku.application import WakuApplication 2 | from waku.factory import WakuFactory 3 | from waku.modules import DynamicModule, Module, module 4 | 5 | __all__ = [ 6 | 'DynamicModule', 7 | 'Module', 8 | 'WakuApplication', 9 | 'WakuFactory', 10 | 'module', 11 | ] 12 | -------------------------------------------------------------------------------- /src/waku/application.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="type-abstract" 2 | from __future__ import annotations 3 | 4 | from contextlib import AsyncExitStack 5 | from typing import TYPE_CHECKING, Self 6 | 7 | from dishka.async_container import AsyncContextWrapper 8 | 9 | from waku.extensions import ( 10 | AfterApplicationInit, 11 | ExtensionRegistry, 12 | OnApplicationInit, 13 | OnApplicationShutdown, 14 | OnModuleDestroy, 15 | OnModuleInit, 16 | ) 17 | from waku.lifespan import LifespanFunc, LifespanWrapper 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Iterable, Sequence 21 | from types import TracebackType 22 | 23 | from waku.di import AsyncContainer 24 | from waku.modules import Module, ModuleRegistry 25 | 26 | __all__ = ['WakuApplication'] 27 | 28 | 29 | class WakuApplication: 30 | __slots__ = ( 31 | '_container', 32 | '_exit_stack', 33 | '_extension_registry', 34 | '_initialized', 35 | '_lifespan', 36 | '_registry', 37 | ) 38 | 39 | def __init__( 40 | self, 41 | *, 42 | container: AsyncContainer, 43 | registry: ModuleRegistry, 44 | lifespan: Sequence[LifespanFunc | LifespanWrapper], 45 | extension_registry: ExtensionRegistry, 46 | ) -> None: 47 | self._container = container 48 | self._registry = registry 49 | self._lifespan = tuple( 50 | LifespanWrapper(lifespan_func) if not isinstance(lifespan_func, LifespanWrapper) else lifespan_func 51 | for lifespan_func in lifespan 52 | ) 53 | self._extension_registry = extension_registry 54 | 55 | self._exit_stack = AsyncExitStack() 56 | self._initialized = False 57 | 58 | async def initialize(self) -> None: 59 | if self._initialized: 60 | return 61 | await self._call_on_init_extensions() 62 | self._initialized = True 63 | await self._call_after_init_extensions() 64 | 65 | async def close(self) -> None: 66 | if not self._initialized: 67 | return 68 | await self._call_on_shutdown_extensions() 69 | self._initialized = False 70 | 71 | @property 72 | def container(self) -> AsyncContainer: 73 | return self._container 74 | 75 | @property 76 | def registry(self) -> ModuleRegistry: 77 | return self._registry 78 | 79 | async def __aenter__(self) -> Self: 80 | await self.initialize() 81 | await self._exit_stack.__aenter__() 82 | for lifespan_wrapper in self._lifespan: 83 | await self._exit_stack.enter_async_context(lifespan_wrapper.lifespan(self)) 84 | await self._exit_stack.enter_async_context(AsyncContextWrapper(self._container)) 85 | return self 86 | 87 | async def __aexit__( 88 | self, 89 | exc_type: type[BaseException] | None, 90 | exc_val: BaseException | None, 91 | exc_tb: TracebackType | None, 92 | ) -> None: 93 | await self.close() 94 | await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) 95 | 96 | async def _call_on_init_extensions(self) -> None: 97 | # Call module OnModuleInit hooks sequentially in topological order (dependencies first) 98 | for module in self._get_modules_for_triggering_extensions(): 99 | for module_ext in self._extension_registry.get_module_extensions(module.target, OnModuleInit): 100 | await module_ext.on_module_init(module) 101 | 102 | for app_ext in self._extension_registry.get_application_extensions(OnApplicationInit): 103 | await app_ext.on_app_init(self) 104 | 105 | async def _call_after_init_extensions(self) -> None: 106 | for extension in self._extension_registry.get_application_extensions(AfterApplicationInit): 107 | await extension.after_app_init(self) 108 | 109 | async def _call_on_shutdown_extensions(self) -> None: 110 | # Call module OnModuleDestroy hooks sequentially in reverse topological order (dependents first) 111 | for module in self._get_modules_for_triggering_extensions(reverse=True): 112 | for module_ext in self._extension_registry.get_module_extensions(module.target, OnModuleDestroy): 113 | await module_ext.on_module_destroy(module) 114 | 115 | for app_ext in self._extension_registry.get_application_extensions(OnApplicationShutdown): 116 | await app_ext.on_app_shutdown(self) 117 | 118 | def _get_modules_for_triggering_extensions(self, *, reverse: bool = False) -> Iterable[Module]: 119 | return reversed(self.registry.modules) if reverse else self.registry.modules 120 | -------------------------------------------------------------------------------- /src/waku/cqrs/__init__.py: -------------------------------------------------------------------------------- 1 | from waku.cqrs.contracts.event import Event 2 | from waku.cqrs.contracts.pipeline import IPipelineBehavior, NextHandlerType 3 | from waku.cqrs.contracts.request import Request, Response 4 | from waku.cqrs.events.handler import EventHandler 5 | from waku.cqrs.impl import Mediator 6 | from waku.cqrs.interfaces import IMediator, IPublisher, ISender 7 | from waku.cqrs.modules import MediatorConfig, MediatorExtension, MediatorModule 8 | from waku.cqrs.requests.handler import RequestHandler 9 | from waku.cqrs.requests.map import RequestMap 10 | 11 | __all__ = [ 12 | 'Event', 13 | 'EventHandler', 14 | 'IMediator', 15 | 'IPipelineBehavior', 16 | 'IPublisher', 17 | 'ISender', 18 | 'Mediator', 19 | 'MediatorConfig', 20 | 'MediatorExtension', 21 | 'MediatorModule', 22 | 'NextHandlerType', 23 | 'Request', 24 | 'RequestHandler', 25 | 'RequestMap', 26 | 'Response', 27 | ] 28 | -------------------------------------------------------------------------------- /src/waku/cqrs/contracts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/src/waku/cqrs/contracts/__init__.py -------------------------------------------------------------------------------- /src/waku/cqrs/contracts/event.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TypeVar 5 | 6 | EventT = TypeVar('EventT', bound='Event', contravariant=True) # noqa: PLC0105 7 | 8 | 9 | @dataclass(frozen=True, kw_only=True) 10 | class Event: 11 | """Base class for events.""" 12 | -------------------------------------------------------------------------------- /src/waku/cqrs/contracts/pipeline.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from collections.abc import Awaitable, Callable 5 | from typing import Generic, TypeAlias 6 | 7 | from waku.cqrs.contracts.request import RequestT, ResponseT 8 | 9 | __all__ = [ 10 | 'IPipelineBehavior', 11 | 'NextHandlerType', 12 | ] 13 | 14 | NextHandlerType: TypeAlias = Callable[[RequestT], Awaitable[ResponseT]] 15 | 16 | 17 | class IPipelineBehavior(ABC, Generic[RequestT, ResponseT]): 18 | """Interface for pipeline behaviors that wrap request handling.""" 19 | 20 | @abstractmethod 21 | async def handle(self, request: RequestT, next_handler: NextHandlerType[RequestT, ResponseT]) -> ResponseT: 22 | """Handle the request and call the next handler in the pipeline. 23 | 24 | Args: 25 | request: The request to handle 26 | next_handler: Function to call the next handler in the pipeline 27 | 28 | Returns: 29 | The response from the pipeline 30 | """ 31 | ... 32 | -------------------------------------------------------------------------------- /src/waku/cqrs/contracts/request.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import uuid 4 | from dataclasses import dataclass, field 5 | from typing import Any, Generic 6 | 7 | from typing_extensions import TypeVar 8 | 9 | __all__ = [ 10 | 'Request', 11 | 'RequestT', 12 | 'Response', 13 | 'ResponseT', 14 | ] 15 | 16 | 17 | RequestT = TypeVar('RequestT', bound='Request[Any]', contravariant=True) # noqa: PLC0105 18 | ResponseT = TypeVar('ResponseT', bound='Response | None', default=None, covariant=True) # noqa: PLC0105 19 | 20 | 21 | @dataclass(frozen=True, kw_only=True) 22 | class Request(Generic[ResponseT]): 23 | """Base class for request-type objects.""" 24 | 25 | request_id: uuid.UUID = field(default_factory=uuid.uuid4) 26 | 27 | 28 | @dataclass(frozen=True, kw_only=True) 29 | class Response: 30 | """Base class for response type objects.""" 31 | -------------------------------------------------------------------------------- /src/waku/cqrs/events/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/src/waku/cqrs/events/__init__.py -------------------------------------------------------------------------------- /src/waku/cqrs/events/handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import Generic, TypeAlias 5 | 6 | from waku.cqrs.contracts.event import EventT 7 | 8 | 9 | class EventHandler(abc.ABC, Generic[EventT]): 10 | """The event handler interface. 11 | 12 | Usage:: 13 | 14 | class UserJoinedEventHandler(EventHandler[UserJoinedEvent]) 15 | def __init__(self, meetings_api: MeetingAPIProtocol) -> None: 16 | self._meetings_api = meetings_api 17 | 18 | async def handle(self, event: UserJoinedEvent) -> None: 19 | await self._meetings_api.notify_room(event.meeting_id, "New user joined!") 20 | 21 | """ 22 | 23 | @abc.abstractmethod 24 | async def handle(self, event: EventT) -> None: 25 | raise NotImplementedError 26 | 27 | 28 | EventHandlerType: TypeAlias = type[EventHandler[EventT]] 29 | -------------------------------------------------------------------------------- /src/waku/cqrs/events/map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from collections.abc import MutableMapping 5 | from typing import Any, Self, TypeAlias 6 | 7 | from waku.cqrs.contracts.event import EventT 8 | from waku.cqrs.events.handler import EventHandlerType 9 | from waku.cqrs.exceptions import EventHandlerAlreadyRegistered 10 | 11 | __all__ = [ 12 | 'EventMap', 13 | 'EventMapRegistry', 14 | ] 15 | 16 | EventMapRegistry: TypeAlias = MutableMapping[type[EventT], list[EventHandlerType[EventT]]] 17 | 18 | 19 | class EventMap: 20 | def __init__(self) -> None: 21 | self._registry: EventMapRegistry[Any] = defaultdict(list) 22 | 23 | def bind(self, event_type: type[EventT], handler_types: list[EventHandlerType[EventT]]) -> Self: 24 | for handler_type in handler_types: 25 | if handler_type in self._registry[event_type]: 26 | raise EventHandlerAlreadyRegistered(event_type, handler_type) 27 | self._registry[event_type].append(handler_type) 28 | return self 29 | 30 | def merge(self, other: EventMap) -> Self: 31 | for event_type, handlers in other.registry.items(): 32 | self.bind(event_type, handlers) 33 | return self 34 | 35 | @property 36 | def registry(self) -> EventMapRegistry[Any]: 37 | return self._registry 38 | 39 | def __bool__(self) -> bool: 40 | return bool(self._registry) 41 | -------------------------------------------------------------------------------- /src/waku/cqrs/events/publish.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Protocol 4 | 5 | import anyio 6 | 7 | if TYPE_CHECKING: 8 | from collections.abc import Sequence 9 | 10 | from waku.cqrs.contracts.event import EventT 11 | from waku.cqrs.events.handler import EventHandler 12 | 13 | __all__ = [ 14 | 'EventPublisher', 15 | 'GroupEventPublisher', 16 | 'SequentialEventPublisher', 17 | ] 18 | 19 | 20 | class EventPublisher(Protocol): 21 | async def __call__(self, handlers: Sequence[EventHandler[EventT]], event: EventT) -> None: 22 | pass 23 | 24 | 25 | class SequentialEventPublisher(EventPublisher): 26 | async def __call__(self, handlers: Sequence[EventHandler[EventT]], event: EventT) -> None: 27 | for handler in handlers: 28 | await handler.handle(event) 29 | 30 | 31 | class GroupEventPublisher(EventPublisher): 32 | async def __call__(self, handlers: Sequence[EventHandler[EventT]], event: EventT) -> None: 33 | async with anyio.create_task_group() as tg: 34 | for handler in handlers: 35 | tg.start_soon(handler.handle, event) 36 | -------------------------------------------------------------------------------- /src/waku/cqrs/exceptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from waku.exceptions import WakuError 6 | 7 | if TYPE_CHECKING: 8 | from waku.cqrs.contracts.event import Event, EventT 9 | from waku.cqrs.contracts.pipeline import IPipelineBehavior 10 | from waku.cqrs.contracts.request import Request 11 | from waku.cqrs.events.handler import EventHandlerType 12 | from waku.cqrs.requests.handler import RequestHandlerType 13 | 14 | __all__ = [ 15 | 'EventHandlerAlreadyRegistered', 16 | 'EventHandlerNotFound', 17 | 'ImproperlyConfiguredError', 18 | 'MediatorError', 19 | 'PipelineBehaviorAlreadyRegistered', 20 | 'RequestHandlerAlreadyRegistered', 21 | 'RequestHandlerNotFound', 22 | ] 23 | 24 | 25 | class MediatorError(WakuError): 26 | """Base exception for all cqrs-related errors.""" 27 | 28 | 29 | class ImproperlyConfiguredError(MediatorError): 30 | """Raised when cqrs configuration is invalid.""" 31 | 32 | 33 | class RequestHandlerAlreadyRegistered(MediatorError, KeyError): # noqa: N818 34 | """Raised when a request handler is already registered. 35 | 36 | Attributes: 37 | request_type: The type of request that caused the error. 38 | handler_type: The type of handler that was already registered. 39 | """ 40 | 41 | def __init__(self, request_type: type[Request[Any]], handler_type: RequestHandlerType[Any, Any]) -> None: 42 | self.request_type = request_type 43 | self.handler_type = handler_type 44 | 45 | def __str__(self) -> str: 46 | return f'{self.request_type.__name__} already exists in registry with handler {self.handler_type.__name__}' 47 | 48 | 49 | class RequestHandlerNotFound(MediatorError, TypeError): # noqa: N818 50 | """Raised when a request handler is not found. 51 | 52 | Attributes: 53 | request_type: The type of request that caused the error. 54 | """ 55 | 56 | def __init__(self, request_type: type[Request[Any]]) -> None: 57 | self.request_type = request_type 58 | 59 | def __str__(self) -> str: 60 | return f'Request handler for {self.request_type.__name__} request is not registered' 61 | 62 | 63 | class EventHandlerAlreadyRegistered(MediatorError, KeyError): # noqa: N818 64 | """Raised when an event handler is already registered. 65 | 66 | Attributes: 67 | event_type: The type of event that caused the error. 68 | handler_type: The type of handler that was already registered. 69 | """ 70 | 71 | def __init__(self, event_type: type[EventT], handler_type: EventHandlerType[EventT]) -> None: 72 | self.event_type = event_type 73 | self.handler_type = handler_type 74 | 75 | def __str__(self) -> str: 76 | return f'{self.handler_type.__name__} already registered for {self.event_type.__name__} event' 77 | 78 | 79 | class EventHandlerNotFound(MediatorError, TypeError): # noqa: N818 80 | """Raised when an event handler is not found. 81 | 82 | Attributes: 83 | event_type: The type of event that caused the error. 84 | """ 85 | 86 | def __init__(self, event_type: type[Event]) -> None: 87 | self.event_type = event_type 88 | 89 | def __str__(self) -> str: 90 | return f'Event handlers for {self.event_type.__name__} event is not registered' 91 | 92 | 93 | class PipelineBehaviorAlreadyRegistered(MediatorError, KeyError): # noqa: N818 94 | """Raised when a pipeline behavior is already registered. 95 | 96 | Attributes: 97 | request_type: The type of request that caused the error. 98 | behavior_type: The type of behavior that was already registered. 99 | """ 100 | 101 | def __init__(self, request_type: type[Request], behavior_type: type[IPipelineBehavior[Any, Any]]) -> None: 102 | self.request_type = request_type 103 | self.behavior_type = behavior_type 104 | 105 | def __str__(self) -> str: 106 | return f'{self.behavior_type.__name__} already registered for {self.request_type.__name__} request' 107 | -------------------------------------------------------------------------------- /src/waku/cqrs/impl.py: -------------------------------------------------------------------------------- 1 | from typing import Any, cast 2 | 3 | from dishka.exceptions import NoFactoryError 4 | from typing_extensions import override 5 | 6 | from waku.cqrs import IPipelineBehavior 7 | from waku.cqrs.contracts.event import Event, EventT 8 | from waku.cqrs.contracts.request import Request, RequestT, ResponseT 9 | from waku.cqrs.events.handler import EventHandler 10 | from waku.cqrs.events.publish import EventPublisher 11 | from waku.cqrs.exceptions import EventHandlerNotFound, RequestHandlerNotFound 12 | from waku.cqrs.interfaces import IMediator 13 | from waku.cqrs.pipeline import PipelineBehaviorWrapper 14 | from waku.cqrs.requests.handler import RequestHandler 15 | from waku.cqrs.utils import get_request_response_type 16 | from waku.di import AsyncContainer 17 | 18 | 19 | class Mediator(IMediator): 20 | """Default CQRS implementation.""" 21 | 22 | def __init__(self, container: AsyncContainer, event_publisher: EventPublisher) -> None: 23 | """Initialize the mediator. 24 | 25 | Args: 26 | container: Container used to resolve handlers and behaviors 27 | event_publisher: Function to publish events to handlers 28 | """ 29 | self._container = container 30 | self._event_publisher = event_publisher 31 | 32 | @override 33 | async def send(self, request: Request[ResponseT]) -> ResponseT: 34 | """Send a request through the CQRS pipeline chain. 35 | 36 | Args: 37 | request: The request to process 38 | 39 | Returns: 40 | Response from the handler 41 | 42 | Raises: 43 | RequestHandlerNotFound: If no handler is registered for the request type 44 | """ 45 | request_type = type(request) 46 | handler = await self._resolve_request_handler(request_type) 47 | return await self._handle_request(handler, request) 48 | 49 | @override 50 | async def publish(self, event: Event) -> None: 51 | """Publish an event to all registered handlers. 52 | 53 | Args: 54 | event: The event to publish 55 | 56 | Raises: 57 | EventHandlerNotFound: If no handlers are registered for the event type 58 | """ 59 | event_type = type(event) 60 | handlers = await self._resolve_event_handlers(event_type) 61 | await self._event_publisher(handlers, event) 62 | 63 | async def _resolve_request_handler( 64 | self, 65 | request_type: type[Request[ResponseT]], 66 | ) -> RequestHandler[Request[ResponseT], ResponseT]: 67 | handler_type = self._get_request_handler_type(request_type) 68 | 69 | try: 70 | return await self._container.get(handler_type) 71 | except NoFactoryError as err: 72 | raise RequestHandlerNotFound(request_type) from err 73 | 74 | async def _handle_request( 75 | self, 76 | handler: RequestHandler[Request[ResponseT], ResponseT], 77 | request: Request[ResponseT], 78 | ) -> ResponseT: 79 | request_type = type(request) 80 | behaviors = await self._resolve_behaviors(request_type) 81 | 82 | pipeline = PipelineBehaviorWrapper(behaviors).wrap(handler.handle) 83 | result = await pipeline(request) 84 | 85 | return cast(ResponseT, result) 86 | 87 | async def _resolve_behaviors(self, request_type: type[Request[Any]]) -> list[IPipelineBehavior[Any, Any]]: 88 | try: 89 | global_behaviors = await self._container.get(list[IPipelineBehavior[Any, Any]]) 90 | except NoFactoryError: 91 | global_behaviors = [] 92 | 93 | response_type = get_request_response_type(request_type) # type: ignore[arg-type] 94 | request_specific_behavior_type = IPipelineBehavior[request_type, response_type] # type: ignore[valid-type] 95 | 96 | try: 97 | request_specific_behaviors = await self._container.get(list[request_specific_behavior_type]) 98 | except NoFactoryError: 99 | request_specific_behaviors = [] 100 | 101 | return [*global_behaviors, *request_specific_behaviors] 102 | 103 | async def _resolve_event_handlers( 104 | self, 105 | event_type: type[EventT], 106 | ) -> list[EventHandler[EventT]]: 107 | handler_type = self._get_event_handler_type(event_type) 108 | 109 | try: 110 | handlers = await self._container.get(list[handler_type]) # type: ignore[valid-type] 111 | return cast(list[EventHandler[EventT]], handlers) 112 | except NoFactoryError as err: 113 | raise EventHandlerNotFound(event_type) from err 114 | 115 | @staticmethod 116 | def _get_request_handler_type(request_type: type[RequestT]) -> type: 117 | response_type = get_request_response_type(request_type) # type: ignore[arg-type] 118 | return RequestHandler[request_type, response_type] # type: ignore[valid-type] 119 | 120 | @staticmethod 121 | def _get_event_handler_type(event_type: type[EventT]) -> type: 122 | return EventHandler[event_type] # type: ignore[valid-type] 123 | -------------------------------------------------------------------------------- /src/waku/cqrs/interfaces.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import TYPE_CHECKING 5 | 6 | if TYPE_CHECKING: 7 | from waku.cqrs.contracts.event import Event 8 | from waku.cqrs.contracts.request import Request, ResponseT 9 | 10 | __all__ = [ 11 | 'IMediator', 12 | 'IPublisher', 13 | 'ISender', 14 | ] 15 | 16 | 17 | class ISender(abc.ABC): 18 | """Send a request through the cqrs middleware chain to be handled by a single handler.""" 19 | 20 | @abc.abstractmethod 21 | async def send(self, request: Request[ResponseT]) -> ResponseT: 22 | """Asynchronously send a request to a single handler.""" 23 | 24 | 25 | class IPublisher(abc.ABC): 26 | """Publish event through the cqrs to be handled by multiple handlers.""" 27 | 28 | @abc.abstractmethod 29 | async def publish(self, event: Event) -> None: 30 | """Asynchronously send event to multiple handlers.""" 31 | 32 | 33 | class IMediator(ISender, IPublisher, abc.ABC): 34 | """Defines a cqrs to encapsulate request/response and publishing interaction patterns.""" 35 | -------------------------------------------------------------------------------- /src/waku/cqrs/modules.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Sequence 2 | from dataclasses import dataclass 3 | from itertools import chain 4 | from typing import Any, Self, TypeAlias 5 | 6 | from waku.cqrs.contracts.event import EventT 7 | from waku.cqrs.contracts.pipeline import IPipelineBehavior 8 | from waku.cqrs.contracts.request import RequestT, ResponseT 9 | from waku.cqrs.events.handler import EventHandler, EventHandlerType 10 | from waku.cqrs.events.map import EventMap 11 | from waku.cqrs.events.publish import EventPublisher, SequentialEventPublisher 12 | from waku.cqrs.impl import Mediator 13 | from waku.cqrs.interfaces import IMediator, IPublisher, ISender 14 | from waku.cqrs.pipeline.map import PipelineBehaviourMap 15 | from waku.cqrs.requests.handler import RequestHandler, RequestHandlerType 16 | from waku.cqrs.requests.map import RequestMap 17 | from waku.cqrs.utils import get_request_response_type 18 | from waku.di import AnyOf, Provider, Scope, scoped, transient 19 | from waku.extensions import OnModuleConfigure 20 | from waku.modules import DynamicModule, ModuleMetadata, module 21 | 22 | __all__ = [ 23 | 'MediatorConfig', 24 | 'MediatorExtension', 25 | 'MediatorModule', 26 | ] 27 | 28 | 29 | _HandlerProviders: TypeAlias = tuple[Provider, ...] 30 | 31 | 32 | @dataclass(frozen=True, slots=True, kw_only=True) 33 | class MediatorConfig: 34 | """Configuration for the Mediator extension. 35 | 36 | This class defines the configuration options for setting up the cqrs pattern 37 | implementation in the application. 38 | 39 | Attributes: 40 | mediator_implementation_type: The concrete implementation class for the cqrs 41 | interface (IMediator). Defaults to the standard Mediator class. 42 | event_publisher: The implementation class for publishing events. Defaults to `SequentialEventPublisher`. 43 | 44 | pipeline_behaviors: A sequence of pipeline behavior configurations that will be applied 45 | to the cqrs pipeline. Behaviors are executed in the order they are defined. 46 | Defaults to an empty sequence. 47 | 48 | Example: 49 | ```python 50 | config = MediatorConfig( 51 | pipeline_behaviors=[ 52 | LoggingBehavior, 53 | ValidationBehavior, 54 | ] 55 | ) 56 | ``` 57 | """ 58 | 59 | mediator_implementation_type: type[IMediator] = Mediator 60 | event_publisher: type[EventPublisher] = SequentialEventPublisher 61 | pipeline_behaviors: Sequence[type[IPipelineBehavior[Any, Any]]] = () 62 | 63 | 64 | @module() 65 | class MediatorModule: 66 | @classmethod 67 | def register(cls, config: MediatorConfig | None = None, /) -> DynamicModule: 68 | """Application-level module for Mediator setup. 69 | 70 | Args: 71 | config: Configuration for the Mediator extension. 72 | """ 73 | config_ = config or MediatorConfig() 74 | return DynamicModule( 75 | parent_module=cls, 76 | providers=[ 77 | *cls._create_mediator_providers(config_), 78 | *cls._create_pipeline_behavior_providers(config_), 79 | ], 80 | is_global=True, 81 | ) 82 | 83 | @staticmethod 84 | def _create_mediator_providers(config: MediatorConfig) -> _HandlerProviders: 85 | return ( 86 | scoped(config.mediator_implementation_type, provided_type=AnyOf[ISender, IPublisher, IMediator]), 87 | scoped(config.event_publisher, provided_type=EventPublisher), 88 | ) 89 | 90 | @staticmethod 91 | def _create_pipeline_behavior_providers(config: MediatorConfig) -> _HandlerProviders: 92 | return (_reg_list(config.pipeline_behaviors, base=IPipelineBehavior[Any, Any]),) 93 | 94 | 95 | class MediatorExtension(OnModuleConfigure): 96 | def __init__(self) -> None: 97 | self._request_map = RequestMap() 98 | self._event_map = EventMap() 99 | self._behavior_map = PipelineBehaviourMap() 100 | 101 | def bind_request( 102 | self, 103 | request_type: type[RequestT], 104 | handler_type: RequestHandlerType[RequestT, ResponseT], 105 | *, 106 | behaviors: list[type[IPipelineBehavior[RequestT, ResponseT]]] | None = None, 107 | ) -> Self: 108 | self._request_map.bind(request_type, handler_type) 109 | if behaviors: 110 | self._behavior_map.bind(request_type, behaviors) 111 | return self 112 | 113 | def bind_event( 114 | self, 115 | event_type: type[EventT], 116 | handler_types: list[EventHandlerType[EventT]], 117 | ) -> Self: 118 | self._event_map.bind(event_type, handler_types) 119 | return self 120 | 121 | def on_module_configure(self, metadata: ModuleMetadata) -> None: 122 | metadata.providers.extend( 123 | chain( 124 | self._create_request_handler_providers(), 125 | self._create_event_handler_providers(), 126 | self._create_pipeline_behavior_providers(), 127 | ), 128 | ) 129 | 130 | def _create_request_handler_providers(self) -> _HandlerProviders: 131 | return tuple( 132 | transient( 133 | handler_type, 134 | provided_type=RequestHandler[request_type, get_request_response_type(request_type)], # type: ignore[arg-type, valid-type, misc] 135 | ) 136 | for request_type, handler_type in self._request_map.registry.items() 137 | ) 138 | 139 | def _create_event_handler_providers(self) -> _HandlerProviders: 140 | return tuple( 141 | _reg_list(handler_types, base=EventHandler[event_type]) # type: ignore[valid-type] 142 | for event_type, handler_types in self._event_map.registry.items() 143 | ) 144 | 145 | def _create_pipeline_behavior_providers(self) -> _HandlerProviders: 146 | return tuple( 147 | _reg_list( 148 | behavior_types, 149 | base=IPipelineBehavior[request_type, get_request_response_type(request_type)], # type: ignore[arg-type, valid-type, misc] 150 | ) 151 | for request_type, behavior_types in self._behavior_map.registry.items() 152 | ) 153 | 154 | 155 | def _reg_list(classes: Sequence[type[Any]], base: type[Any]) -> Provider: 156 | provider = Provider(scope=Scope.REQUEST) 157 | provider.provide_all(*classes, cache=False) 158 | provider.provide(lambda: [], provides=list[base], cache=False) # type: ignore[valid-type] # noqa: PIE807 159 | 160 | for cls in classes: 161 | 162 | @provider.decorate 163 | def foo(many: list[base], one: cls) -> list[base]: # type: ignore[valid-type] 164 | return [*many, one] 165 | 166 | return provider 167 | -------------------------------------------------------------------------------- /src/waku/cqrs/pipeline/__init__.py: -------------------------------------------------------------------------------- 1 | from waku.cqrs.pipeline.chain import PipelineBehaviorWrapper 2 | 3 | __all__ = [ 4 | 'PipelineBehaviorWrapper', 5 | ] 6 | -------------------------------------------------------------------------------- /src/waku/cqrs/pipeline/chain.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from typing import TYPE_CHECKING, Generic 5 | 6 | from waku.cqrs.contracts.request import RequestT, ResponseT 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Sequence 10 | 11 | from waku.cqrs.contracts.pipeline import IPipelineBehavior, NextHandlerType 12 | 13 | 14 | class PipelineBehaviorWrapper(Generic[RequestT, ResponseT]): 15 | """Composes pipeline behaviors into a processing chain.""" 16 | 17 | def __init__(self, behaviors: Sequence[IPipelineBehavior[RequestT, ResponseT]]) -> None: 18 | """Initialize the pipeline behavior chain. 19 | 20 | Args: 21 | behaviors: Sequence of pipeline behaviors to execute in order 22 | """ 23 | self._behaviors = list(behaviors) # Convert to list immediately 24 | 25 | def wrap(self, handle: NextHandlerType[RequestT, ResponseT]) -> NextHandlerType[RequestT, ResponseT]: 26 | """Create a pipeline that wraps the handler function with behaviors. 27 | 28 | Pipeline behaviors are executed in the order they are provided. 29 | 30 | Args: 31 | handle: The handler function to wrap with behaviors 32 | 33 | Returns: 34 | A function that executes the entire pipeline 35 | """ 36 | for behavior in reversed(self._behaviors): 37 | handle = functools.partial(behavior.handle, next_handler=handle) 38 | 39 | return handle 40 | -------------------------------------------------------------------------------- /src/waku/cqrs/pipeline/map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import defaultdict 4 | from collections.abc import MutableMapping 5 | from typing import Any, Self 6 | 7 | from waku.cqrs.contracts.pipeline import IPipelineBehavior 8 | from waku.cqrs.contracts.request import RequestT, ResponseT 9 | from waku.cqrs.exceptions import PipelineBehaviorAlreadyRegistered 10 | 11 | PipelineBehaviorMapRegistry = MutableMapping[type[RequestT], list[type[IPipelineBehavior[RequestT, ResponseT]]]] 12 | 13 | 14 | class PipelineBehaviourMap: 15 | def __init__(self) -> None: 16 | self._registry: PipelineBehaviorMapRegistry[Any, Any] = defaultdict(list) 17 | 18 | def bind( 19 | self, 20 | request_type: type[RequestT], 21 | behavior_types: list[type[IPipelineBehavior[RequestT, ResponseT]]], 22 | ) -> Self: 23 | for behavior_type in behavior_types: 24 | if behavior_type in self._registry[request_type]: 25 | raise PipelineBehaviorAlreadyRegistered(request_type, behavior_type) 26 | self._registry[request_type].append(behavior_type) 27 | return self 28 | 29 | def merge(self, other: PipelineBehaviourMap) -> Self: 30 | for event_type, handlers in other.registry.items(): 31 | self.bind(event_type, handlers) 32 | return self 33 | 34 | @property 35 | def registry(self) -> PipelineBehaviorMapRegistry[Any, Any]: 36 | return self._registry 37 | 38 | def __bool__(self) -> bool: 39 | return bool(self._registry) 40 | -------------------------------------------------------------------------------- /src/waku/cqrs/requests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/src/waku/cqrs/requests/__init__.py -------------------------------------------------------------------------------- /src/waku/cqrs/requests/handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import Generic, TypeAlias 5 | 6 | from waku.cqrs.contracts.request import RequestT, ResponseT 7 | 8 | __all__ = [ 9 | 'RequestHandler', 10 | 'RequestHandlerType', 11 | ] 12 | 13 | 14 | class RequestHandler(abc.ABC, Generic[RequestT, ResponseT]): 15 | """The request handler interface. 16 | 17 | The request handler is an object, which gets a request as input and may return a response as a result. 18 | 19 | Command handler example:: 20 | 21 | class JoinMeetingCommandHandler(RequestHandler[JoinMeetingCommand, None]) 22 | def __init__(self, meetings_api: MeetingAPIProtocol) -> None: 23 | self._meetings_api = meetings_api 24 | 25 | async def handle(self, request: JoinMeetingCommand) -> None: 26 | await self._meetings_api.join_user(request.user_id, request.meeting_id) 27 | 28 | Query handler example:: 29 | 30 | class ReadMeetingQueryHandler(RequestHandler[ReadMeetingQuery, ReadMeetingQueryResult]) 31 | def __init__(self, meetings_api: MeetingAPIProtocol) -> None: 32 | self._meetings_api = meetings_api 33 | 34 | async def handle(self, request: ReadMeetingQuery) -> ReadMeetingQueryResult: 35 | link = await self._meetings_api.get_link(request.meeting_id) 36 | return ReadMeetingQueryResult(link=link, meeting_id=request.meeting_id) 37 | 38 | """ 39 | 40 | @abc.abstractmethod 41 | async def handle(self, request: RequestT) -> ResponseT: 42 | raise NotImplementedError 43 | 44 | 45 | RequestHandlerType: TypeAlias = type[RequestHandler[RequestT, ResponseT]] 46 | -------------------------------------------------------------------------------- /src/waku/cqrs/requests/map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import MutableMapping 4 | from typing import Any, Self, TypeAlias 5 | 6 | from waku.cqrs.contracts.request import RequestT, ResponseT 7 | from waku.cqrs.exceptions import RequestHandlerAlreadyRegistered 8 | from waku.cqrs.requests.handler import RequestHandlerType 9 | 10 | __all__ = [ 11 | 'RequestMap', 12 | 'RequestMapRegistry', 13 | ] 14 | 15 | RequestMapRegistry: TypeAlias = MutableMapping[type[RequestT], RequestHandlerType[RequestT, ResponseT]] 16 | 17 | 18 | class RequestMap: 19 | def __init__(self) -> None: 20 | self._registry: RequestMapRegistry[Any, Any] = {} 21 | 22 | def bind(self, request_type: type[RequestT], handler_type: RequestHandlerType[RequestT, ResponseT]) -> Self: 23 | if request_type in self._registry: 24 | raise RequestHandlerAlreadyRegistered(request_type, handler_type) 25 | self._registry[request_type] = handler_type 26 | return self 27 | 28 | def merge(self, other: RequestMap) -> Self: 29 | for request_type, handler_type in other.registry.items(): 30 | self.bind(request_type, handler_type) 31 | return self 32 | 33 | @property 34 | def registry(self) -> RequestMapRegistry[Any, Any]: 35 | return self._registry 36 | 37 | def __bool__(self) -> bool: 38 | return bool(self._registry) 39 | -------------------------------------------------------------------------------- /src/waku/cqrs/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import typing 5 | 6 | from typing_extensions import get_original_bases 7 | 8 | from waku.cqrs.contracts.request import ResponseT 9 | 10 | if typing.TYPE_CHECKING: 11 | from waku.cqrs.contracts.request import Request 12 | 13 | __all__ = ['get_request_response_type'] 14 | 15 | 16 | @functools.cache 17 | def get_request_response_type(request_type: type[Request[ResponseT]]) -> type[ResponseT]: 18 | return typing.cast(type[ResponseT], typing.get_args(get_original_bases(request_type)[0])[0]) 19 | -------------------------------------------------------------------------------- /src/waku/di.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any 3 | 4 | from dishka import ( 5 | DEFAULT_COMPONENT, 6 | AnyOf, 7 | AsyncContainer, 8 | FromComponent, 9 | FromDishka as Injected, 10 | Provider, 11 | Scope, 12 | WithParents, 13 | alias, 14 | from_context, 15 | provide, 16 | provide_all, 17 | ) 18 | from dishka.provider import BaseProvider 19 | 20 | __all__ = [ 21 | 'DEFAULT_COMPONENT', 22 | 'AnyOf', 23 | 'AsyncContainer', 24 | 'BaseProvider', 25 | 'FromComponent', 26 | 'Injected', 27 | 'Provider', 28 | 'Scope', 29 | 'WithParents', 30 | 'alias', 31 | 'contextual', 32 | 'from_context', 33 | 'object_', 34 | 'provide', 35 | 'provide_all', 36 | 'provider', 37 | 'scoped', 38 | 'singleton', 39 | 'transient', 40 | ] 41 | 42 | 43 | def provider( 44 | source: Callable[..., Any] | type[Any], 45 | *, 46 | scope: Scope = Scope.REQUEST, 47 | provided_type: Any = None, 48 | cache: bool = True, 49 | ) -> Provider: 50 | """Create a Dishka provider for a callable or type. 51 | 52 | Args: 53 | source: Callable or type to provide as a dependency. 54 | scope: Scope of the dependency (default: Scope.REQUEST). 55 | provided_type: Explicit type to provide (default: inferred). 56 | cache: Whether to cache the instance in the scope. 57 | 58 | Returns: 59 | Provider: Configured provider instance. 60 | """ 61 | provider_ = Provider(scope=scope) 62 | provider_.provide(source, provides=provided_type, cache=cache) 63 | return provider_ 64 | 65 | 66 | def singleton( 67 | source: Callable[..., Any] | type[Any], 68 | *, 69 | provided_type: Any = None, 70 | ) -> Provider: 71 | """Create a singleton provider (lifetime: app). 72 | 73 | Args: 74 | source: Callable or type to provide as a singleton. 75 | provided_type: Explicit type to provide (default: inferred). 76 | 77 | Returns: 78 | Provider: Singleton provider instance. 79 | """ 80 | return provider(source, scope=Scope.APP, provided_type=provided_type) 81 | 82 | 83 | def scoped( 84 | source: Callable[..., Any] | type[Any], 85 | *, 86 | provided_type: Any = None, 87 | ) -> Provider: 88 | """Create a scoped provider (lifetime: request). 89 | 90 | Args: 91 | source: Callable or type to provide as a scoped dependency. 92 | provided_type: Explicit type to provide (default: inferred). 93 | 94 | Returns: 95 | Provider: Scoped provider instance. 96 | """ 97 | return provider(source, scope=Scope.REQUEST, provided_type=provided_type) 98 | 99 | 100 | def transient( 101 | source: Callable[..., Any] | type[Any], 102 | *, 103 | provided_type: Any = None, 104 | ) -> Provider: 105 | """Create a transient provider (new instance per injection). 106 | 107 | Args: 108 | source: Callable or type to provide as a transient dependency. 109 | provided_type: Explicit type to provide (default: inferred). 110 | 111 | Returns: 112 | Provider: Transient provider instance. 113 | """ 114 | return provider(source, scope=Scope.REQUEST, provided_type=provided_type, cache=False) 115 | 116 | 117 | def object_( 118 | source: Any, 119 | *, 120 | provided_type: Any = None, 121 | ) -> Provider: 122 | """Provide the exact object passed at creation time as a singleton dependency. 123 | 124 | The provider always returns the same object instance, without instantiation or copying. 125 | 126 | Args: 127 | source: The object to provide as-is. 128 | provided_type: Explicit type to provide (default: inferred). 129 | 130 | Returns: 131 | Provider: Provider that always returns the given object. 132 | """ 133 | return provider(lambda: source, scope=Scope.APP, provided_type=provided_type, cache=True) 134 | 135 | 136 | def contextual( 137 | provided_type: Any, 138 | *, 139 | scope: Scope = Scope.REQUEST, 140 | ) -> Provider: 141 | """Provide a dependency from the current context (e.g., app/request). 142 | 143 | Args: 144 | provided_type: The type to resolve from context. 145 | scope: Scope of the context variable (default: Scope.REQUEST). 146 | 147 | Returns: 148 | Provider: Contextual provider instance. 149 | """ 150 | provider_ = Provider() 151 | provider_.from_context(provided_type, scope=scope) 152 | return provider_ 153 | -------------------------------------------------------------------------------- /src/waku/exceptions.py: -------------------------------------------------------------------------------- 1 | class WakuError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/waku/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from waku.extensions.protocols import ( 6 | AfterApplicationInit, 7 | ApplicationExtension, 8 | ModuleExtension, 9 | OnApplicationInit, 10 | OnApplicationShutdown, 11 | OnModuleConfigure, 12 | OnModuleDestroy, 13 | OnModuleInit, 14 | ) 15 | from waku.extensions.registry import ExtensionRegistry 16 | from waku.validation import ValidationExtension 17 | from waku.validation.rules import DependenciesAccessibleRule 18 | 19 | if TYPE_CHECKING: 20 | from collections.abc import Sequence 21 | 22 | __all__ = [ 23 | 'DEFAULT_EXTENSIONS', 24 | 'AfterApplicationInit', 25 | 'ApplicationExtension', 26 | 'ExtensionRegistry', 27 | 'ModuleExtension', 28 | 'OnApplicationInit', 29 | 'OnApplicationShutdown', 30 | 'OnModuleConfigure', 31 | 'OnModuleDestroy', 32 | 'OnModuleInit', 33 | ] 34 | 35 | 36 | DEFAULT_EXTENSIONS: Sequence[ApplicationExtension] = ( 37 | ValidationExtension( 38 | [DependenciesAccessibleRule()], 39 | strict=True, 40 | ), 41 | ) 42 | -------------------------------------------------------------------------------- /src/waku/extensions/protocols.py: -------------------------------------------------------------------------------- 1 | """Extension protocols for application and module lifecycle hooks.""" 2 | 3 | from __future__ import annotations 4 | 5 | from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable 6 | 7 | if TYPE_CHECKING: 8 | from waku.application import WakuApplication 9 | from waku.modules import Module, ModuleMetadata 10 | 11 | __all__ = [ 12 | 'AfterApplicationInit', 13 | 'ApplicationExtension', 14 | 'ModuleExtension', 15 | 'OnApplicationInit', 16 | 'OnApplicationShutdown', 17 | 'OnModuleConfigure', 18 | 'OnModuleDestroy', 19 | 'OnModuleInit', 20 | ] 21 | 22 | 23 | @runtime_checkable 24 | class OnApplicationInit(Protocol): 25 | """Extension for application pre-initialization actions.""" 26 | 27 | __slots__ = () 28 | 29 | async def on_app_init(self, app: WakuApplication) -> None: 30 | """Perform actions before application initialization.""" 31 | 32 | 33 | @runtime_checkable 34 | class AfterApplicationInit(Protocol): 35 | """Extension for application post-initialization actions.""" 36 | 37 | __slots__ = () 38 | 39 | async def after_app_init(self, app: WakuApplication) -> None: 40 | """Perform actions after application initialization.""" 41 | 42 | 43 | @runtime_checkable 44 | class OnApplicationShutdown(Protocol): 45 | """Extension for application shutdown actions.""" 46 | 47 | __slots__ = () 48 | 49 | async def on_app_shutdown(self, app: WakuApplication) -> None: 50 | """Perform actions before application shutdown.""" 51 | 52 | 53 | @runtime_checkable 54 | class OnModuleConfigure(Protocol): 55 | """Extension for module configuration.""" 56 | 57 | __slots__ = () 58 | 59 | def on_module_configure(self, metadata: ModuleMetadata) -> None: 60 | """Perform actions before module metadata transformed to module.""" 61 | ... 62 | 63 | 64 | @runtime_checkable 65 | class OnModuleInit(Protocol): 66 | """Extension for module initialization.""" 67 | 68 | __slots__ = () 69 | 70 | async def on_module_init(self, module: Module) -> None: 71 | """Perform actions before application initialization.""" 72 | ... 73 | 74 | 75 | @runtime_checkable 76 | class OnModuleDestroy(Protocol): 77 | """Extension for module destroying.""" 78 | 79 | __slots__ = () 80 | 81 | async def on_module_destroy(self, module: Module) -> None: 82 | """Perform actions before application shutdown.""" 83 | ... 84 | 85 | 86 | ApplicationExtension: TypeAlias = OnApplicationInit | AfterApplicationInit | OnApplicationShutdown 87 | ModuleExtension: TypeAlias = OnModuleConfigure | OnModuleInit | OnModuleDestroy 88 | -------------------------------------------------------------------------------- /src/waku/extensions/registry.py: -------------------------------------------------------------------------------- 1 | """Extension registry for centralized management of extensions.""" 2 | 3 | from __future__ import annotations 4 | 5 | import inspect 6 | from collections import defaultdict 7 | from typing import TYPE_CHECKING, Self, TypeVar, cast 8 | 9 | from waku.extensions.protocols import ApplicationExtension, ModuleExtension 10 | 11 | if TYPE_CHECKING: 12 | from waku.modules import ModuleType 13 | 14 | __all__ = ['ExtensionRegistry'] 15 | 16 | 17 | _AppExtT = TypeVar('_AppExtT', bound=ApplicationExtension) 18 | _ModExtT = TypeVar('_ModExtT', bound=ModuleExtension) 19 | 20 | 21 | class ExtensionRegistry: 22 | """Registry for extensions. 23 | 24 | This registry maintains references to all extensions in the application, 25 | allowing for centralized management and discovery. 26 | """ 27 | 28 | def __init__(self) -> None: 29 | self._app_extensions: dict[type[ApplicationExtension], list[ApplicationExtension]] = defaultdict(list) 30 | self._module_extensions: dict[ModuleType, list[ModuleExtension]] = defaultdict(list) 31 | 32 | def register_application_extension(self, extension: ApplicationExtension) -> Self: 33 | """Register an application extension with optional priority and tags.""" 34 | ext_type = type(extension) 35 | extension_bases = [ 36 | base 37 | for base in inspect.getmro(ext_type) 38 | if (isinstance(base, ApplicationExtension) and base != ext_type) # type: ignore[unreachable] 39 | ] 40 | for base in extension_bases: 41 | self._app_extensions[cast(type[ApplicationExtension], base)].append(extension) 42 | return self 43 | 44 | def register_module_extension(self, module_type: ModuleType, extension: ModuleExtension) -> Self: 45 | self._module_extensions[module_type].append(extension) 46 | return self 47 | 48 | def get_application_extensions(self, extension_type: type[_AppExtT]) -> list[_AppExtT]: 49 | return cast(list[_AppExtT], self._app_extensions.get(cast(type[ApplicationExtension], extension_type), [])) 50 | 51 | def get_module_extensions(self, module_type: ModuleType, extension_type: type[_ModExtT]) -> list[_ModExtT]: 52 | extensions = cast(list[_ModExtT], self._module_extensions.get(module_type, [])) 53 | return [ext for ext in extensions if isinstance(ext, extension_type)] 54 | -------------------------------------------------------------------------------- /src/waku/factory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import Lock 4 | from collections.abc import Callable, Iterable 5 | from contextlib import AbstractAsyncContextManager 6 | from dataclasses import dataclass 7 | from typing import TYPE_CHECKING, Any, TypeAlias 8 | 9 | from dishka import STRICT_VALIDATION, make_async_container 10 | 11 | from waku.application import WakuApplication 12 | from waku.extensions import DEFAULT_EXTENSIONS, ExtensionRegistry 13 | from waku.modules import ModuleRegistryBuilder 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Sequence 17 | 18 | from waku import Module 19 | from waku.di import AsyncContainer, BaseProvider, Scope 20 | from waku.extensions import ApplicationExtension 21 | from waku.lifespan import LifespanFunc 22 | from waku.modules import ModuleType 23 | 24 | __all__ = [ 25 | 'ContainerConfig', 26 | 'WakuFactory', 27 | ] 28 | 29 | _LockFactory: TypeAlias = Callable[[], AbstractAsyncContextManager[Any]] 30 | 31 | 32 | @dataclass(frozen=True, slots=True, kw_only=True) 33 | class ContainerConfig: 34 | lock_factory: _LockFactory = Lock 35 | start_scope: Scope | None = None 36 | skip_validation: bool = False 37 | 38 | 39 | class WakuFactory: 40 | def __init__( 41 | self, 42 | root_module_type: ModuleType, 43 | /, 44 | context: dict[Any, Any] | None = None, 45 | lifespan: Sequence[LifespanFunc] = (), 46 | extensions: Sequence[ApplicationExtension] = DEFAULT_EXTENSIONS, 47 | container_config: ContainerConfig | None = None, 48 | ) -> None: 49 | self._root_module_type = root_module_type 50 | 51 | self._context = context 52 | self._lifespan = lifespan 53 | self._extensions = extensions 54 | self._container_config = container_config or ContainerConfig() 55 | 56 | def create(self) -> WakuApplication: 57 | registry = ModuleRegistryBuilder(self._root_module_type).build() 58 | container = self._build_container(registry.providers) 59 | return WakuApplication( 60 | container=container, 61 | registry=registry, 62 | lifespan=self._lifespan, 63 | extension_registry=self._build_extension_registry(registry.modules), 64 | ) 65 | 66 | def _build_extension_registry(self, modules: Iterable[Module]) -> ExtensionRegistry: 67 | extension_registry = ExtensionRegistry() 68 | for app_extension in self._extensions: 69 | extension_registry.register_application_extension(app_extension) 70 | for module in modules: 71 | for module_extension in module.extensions: 72 | extension_registry.register_module_extension(module.target, module_extension) 73 | return extension_registry 74 | 75 | def _build_container(self, providers: Sequence[BaseProvider]) -> AsyncContainer: 76 | return make_async_container( 77 | *providers, 78 | context=self._context, 79 | lock_factory=self._container_config.lock_factory, 80 | start_scope=self._container_config.start_scope, 81 | skip_validation=self._container_config.skip_validation, 82 | validation_settings=STRICT_VALIDATION, 83 | ) 84 | -------------------------------------------------------------------------------- /src/waku/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from contextlib import AbstractAsyncContextManager, asynccontextmanager 5 | from typing import TYPE_CHECKING, TypeAlias, final 6 | 7 | if TYPE_CHECKING: 8 | from collections.abc import AsyncIterator 9 | 10 | from waku.application import WakuApplication 11 | 12 | __all__ = [ 13 | 'LifespanFunc', 14 | 'LifespanWrapper', 15 | ] 16 | 17 | LifespanFunc: TypeAlias = ( 18 | Callable[['WakuApplication'], AbstractAsyncContextManager[None]] | AbstractAsyncContextManager[None] 19 | ) 20 | 21 | 22 | @final 23 | class LifespanWrapper: 24 | def __init__(self, lifespan_func: LifespanFunc) -> None: 25 | self._lifespan_func = lifespan_func 26 | 27 | @asynccontextmanager 28 | async def lifespan(self, app: WakuApplication) -> AsyncIterator[None]: 29 | ctx_manager = ( 30 | self._lifespan_func 31 | if isinstance(self._lifespan_func, AbstractAsyncContextManager) 32 | else self._lifespan_func(app) 33 | ) 34 | async with ctx_manager: 35 | yield 36 | -------------------------------------------------------------------------------- /src/waku/modules/__init__.py: -------------------------------------------------------------------------------- 1 | from waku.modules._metadata import DynamicModule, HasModuleMetadata, ModuleCompiler, ModuleMetadata, ModuleType, module 2 | from waku.modules._module import Module 3 | from waku.modules._registry import ModuleRegistry 4 | from waku.modules._registry_builder import ModuleRegistryBuilder 5 | 6 | __all__ = [ 7 | 'DynamicModule', 8 | 'HasModuleMetadata', 9 | 'Module', 10 | 'ModuleCompiler', 11 | 'ModuleMetadata', 12 | 'ModuleRegistry', 13 | 'ModuleRegistryBuilder', 14 | 'ModuleType', 15 | 'module', 16 | ] 17 | -------------------------------------------------------------------------------- /src/waku/modules/_metadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | import uuid 5 | from collections.abc import Hashable 6 | from dataclasses import dataclass, field 7 | from typing import TYPE_CHECKING, Final, Protocol, TypeAlias, TypeVar, cast, runtime_checkable 8 | 9 | from waku.extensions import OnModuleConfigure 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Callable, Sequence 13 | 14 | from waku.di import BaseProvider 15 | from waku.extensions import ModuleExtension 16 | 17 | __all__ = [ 18 | 'DynamicModule', 19 | 'HasModuleMetadata', 20 | 'ModuleCompiler', 21 | 'ModuleMetadata', 22 | 'ModuleType', 23 | 'module', 24 | ] 25 | 26 | _T = TypeVar('_T') 27 | 28 | 29 | _MODULE_METADATA_KEY: Final = '__module_metadata__' 30 | 31 | 32 | @dataclass(kw_only=True, slots=True) 33 | class ModuleMetadata: 34 | providers: list[BaseProvider] = field(default_factory=list) 35 | """List of providers for dependency injection.""" 36 | imports: list[ModuleType | DynamicModule] = field(default_factory=list) 37 | """List of modules imported by this module.""" 38 | exports: list[type[object] | ModuleType | DynamicModule] = field(default_factory=list) 39 | """List of types or modules exported by this module.""" 40 | extensions: list[ModuleExtension] = field(default_factory=list) 41 | """List of module extensions for lifecycle hooks.""" 42 | is_global: bool = False 43 | """Whether this module is global or not.""" 44 | 45 | id: uuid.UUID = field(default_factory=uuid.uuid4) 46 | 47 | def __hash__(self) -> int: 48 | return hash(self.id) 49 | 50 | 51 | @runtime_checkable 52 | class HasModuleMetadata(Protocol): 53 | __module_metadata__: ModuleMetadata 54 | 55 | 56 | ModuleType: TypeAlias = type[object | HasModuleMetadata] 57 | 58 | 59 | @dataclass(kw_only=True, slots=True) 60 | class DynamicModule(ModuleMetadata): 61 | parent_module: ModuleType 62 | 63 | def __hash__(self) -> int: 64 | return hash(self.id) 65 | 66 | 67 | def module( 68 | *, 69 | providers: Sequence[BaseProvider] = (), 70 | imports: Sequence[ModuleType | DynamicModule] = (), 71 | exports: Sequence[type[object] | ModuleType | DynamicModule] = (), 72 | extensions: Sequence[ModuleExtension] = (), 73 | is_global: bool = False, 74 | ) -> Callable[[type[_T]], type[_T]]: 75 | """Decorator to define a module. 76 | 77 | Args: 78 | providers: Sequence of providers for dependency injection. 79 | imports: Sequence of modules imported by this module. 80 | exports: Sequence of types or modules exported by this module. 81 | extensions: Sequence of module extensions for lifecycle hooks. 82 | is_global: Whether this module is global or not. 83 | """ 84 | 85 | def decorator(cls: type[_T]) -> type[_T]: 86 | metadata = ModuleMetadata( 87 | providers=list(providers), 88 | imports=list(imports), 89 | exports=list(exports), 90 | extensions=list(extensions), 91 | is_global=is_global, 92 | ) 93 | for extension in metadata.extensions: 94 | if isinstance(extension, OnModuleConfigure): 95 | extension.on_module_configure(metadata) 96 | 97 | setattr(cls, _MODULE_METADATA_KEY, metadata) 98 | return cls 99 | 100 | return decorator 101 | 102 | 103 | class ModuleCompiler: 104 | def extract_metadata(self, module_type: ModuleType | DynamicModule) -> tuple[ModuleType, ModuleMetadata]: 105 | try: 106 | return self._extract_metadata(cast(Hashable, module_type)) 107 | except AttributeError: 108 | msg = f'{type(module_type).__name__} is not module' 109 | raise ValueError(msg) from None 110 | 111 | @staticmethod 112 | @functools.cache 113 | def _extract_metadata(module_type: ModuleType | DynamicModule) -> tuple[ModuleType, ModuleMetadata]: 114 | if isinstance(module_type, DynamicModule): 115 | parent_module = module_type.parent_module 116 | parent_metadata = cast(ModuleMetadata, getattr(parent_module, _MODULE_METADATA_KEY)) 117 | metadata = ModuleMetadata( 118 | providers=[*parent_metadata.providers, *module_type.providers], 119 | imports=[*parent_metadata.imports, *module_type.imports], 120 | exports=[*parent_metadata.exports, *module_type.exports], 121 | extensions=[*parent_metadata.extensions, *module_type.extensions], 122 | is_global=module_type.is_global, 123 | id=module_type.id, 124 | ) 125 | for extension in metadata.extensions: 126 | if isinstance(extension, OnModuleConfigure): 127 | extension.on_module_configure(metadata) 128 | return parent_module, metadata 129 | 130 | return module_type, cast(ModuleMetadata, getattr(module_type, _MODULE_METADATA_KEY)) 131 | -------------------------------------------------------------------------------- /src/waku/modules/_module.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from typing import TYPE_CHECKING, Final, cast 5 | 6 | from waku.di import DEFAULT_COMPONENT, BaseProvider 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Iterable, Sequence 10 | from uuid import UUID 11 | 12 | from waku.extensions import ModuleExtension 13 | from waku.modules._metadata import DynamicModule, ModuleMetadata, ModuleType 14 | 15 | 16 | __all__ = ['Module'] 17 | 18 | 19 | class Module: 20 | __slots__ = ( 21 | '__dict__', 22 | '_provider', 23 | 'exports', 24 | 'extensions', 25 | 'id', 26 | 'imports', 27 | 'is_global', 28 | 'providers', 29 | 'target', 30 | ) 31 | 32 | def __init__(self, module_type: ModuleType, metadata: ModuleMetadata) -> None: 33 | self.id: Final[UUID] = metadata.id 34 | self.target: Final[ModuleType] = module_type 35 | 36 | self.providers: Final[Sequence[BaseProvider]] = metadata.providers 37 | self.imports: Final[Sequence[ModuleType | DynamicModule]] = metadata.imports 38 | self.exports: Final[Sequence[type[object] | ModuleType | DynamicModule]] = metadata.exports 39 | self.extensions: Final[Sequence[ModuleExtension]] = metadata.extensions 40 | self.is_global: Final[bool] = metadata.is_global 41 | 42 | self._provider: BaseProvider | None = None 43 | 44 | @property 45 | def name(self) -> str: 46 | return self.target.__name__ 47 | 48 | @cached_property 49 | def provider(self) -> BaseProvider: 50 | cls = cast(type[_ModuleProvider], type(f'{self.name}Provider', (_ModuleProvider,), {})) 51 | return cls(self.providers) 52 | 53 | def __str__(self) -> str: 54 | return self.__repr__() 55 | 56 | def __repr__(self) -> str: 57 | return f'Module[{self.name}]' 58 | 59 | def __hash__(self) -> int: 60 | return hash(self.id) 61 | 62 | def __eq__(self, other: object) -> bool: 63 | if not isinstance(other, Module): # pragma: no cover 64 | return False 65 | return self.id == other.id 66 | 67 | 68 | class _ModuleProvider(BaseProvider): 69 | def __init__(self, providers: Iterable[BaseProvider]) -> None: 70 | super().__init__(DEFAULT_COMPONENT) 71 | for provider in providers: 72 | self.factories.extend(provider.factories) 73 | self.aliases.extend(provider.aliases) 74 | self.decorators.extend(provider.decorators) 75 | self.context_vars.extend(provider.context_vars) 76 | -------------------------------------------------------------------------------- /src/waku/modules/_registry.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from collections.abc import Iterator 7 | from uuid import UUID 8 | 9 | from waku.di import BaseProvider 10 | from waku.modules._metadata import DynamicModule, ModuleCompiler, ModuleType 11 | from waku.modules._module import Module 12 | from waku.modules._registry_builder import AdjacencyMatrix 13 | 14 | 15 | __all__ = ['ModuleRegistry'] 16 | 17 | 18 | class ModuleRegistry: 19 | """Immutable registry and graph for module queries, traversal, and lookups.""" 20 | 21 | def __init__( 22 | self, 23 | *, 24 | compiler: ModuleCompiler, 25 | root_module: Module, 26 | modules: dict[UUID, Module], 27 | providers: list[BaseProvider], 28 | adjacency: AdjacencyMatrix, 29 | ) -> None: 30 | self._compiler = compiler 31 | self._root_module = root_module 32 | self._modules = modules 33 | self._providers = tuple(providers) 34 | self._adjacency = adjacency 35 | 36 | @property 37 | def root_module(self) -> Module: 38 | return self._root_module 39 | 40 | @property 41 | def modules(self) -> tuple[Module, ...]: 42 | return tuple(self._modules.values()) 43 | 44 | @property 45 | def providers(self) -> tuple[BaseProvider, ...]: 46 | return self._providers 47 | 48 | @property 49 | def compiler(self) -> ModuleCompiler: 50 | return self._compiler 51 | 52 | def get(self, module_type: ModuleType | DynamicModule) -> Module: 53 | module_id = self.compiler.extract_metadata(module_type)[1].id 54 | return self.get_by_id(module_id) 55 | 56 | def get_by_id(self, module_id: UUID) -> Module: 57 | module = self._modules.get(module_id) 58 | if module is None: 59 | msg = f'Module with ID {module_id} is not registered in the graph.' 60 | raise KeyError(msg) 61 | return module 62 | 63 | def traverse(self, from_: Module | None = None) -> Iterator[Module]: 64 | """Traverse the module graph in depth-first post-order (children before parent) recursively. 65 | 66 | Args: 67 | from_: Start module (default: root) 68 | 69 | Yields: 70 | Module: Each traversed module (post-order) 71 | """ 72 | start_module = from_ or self._root_module 73 | visited: set[UUID] = set() 74 | 75 | def _dfs(module: Module) -> Iterator[Module]: 76 | if module.id in visited: 77 | return 78 | 79 | visited.add(module.id) 80 | 81 | # Process children first (maintain original order) 82 | neighbor_ids = self._adjacency[module.id] 83 | for neighbor_id in neighbor_ids: 84 | if neighbor_id == module.id: 85 | continue 86 | neighbor = self.get_by_id(neighbor_id) 87 | yield from _dfs(neighbor) 88 | 89 | # Process current module after children (post-order) 90 | yield module 91 | 92 | yield from _dfs(start_module) 93 | -------------------------------------------------------------------------------- /src/waku/modules/_registry_builder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import OrderedDict, defaultdict 4 | from typing import TYPE_CHECKING, Final, TypeAlias 5 | from uuid import UUID 6 | 7 | from waku.modules import Module, ModuleCompiler, ModuleMetadata, ModuleRegistry, ModuleType 8 | 9 | if TYPE_CHECKING: 10 | from waku import DynamicModule 11 | from waku.di import BaseProvider 12 | 13 | 14 | __all__ = [ 15 | 'AdjacencyMatrix', 16 | 'ModuleRegistryBuilder', 17 | ] 18 | 19 | 20 | AdjacencyMatrix: TypeAlias = dict[UUID, OrderedDict[UUID, str]] 21 | 22 | 23 | class ModuleRegistryBuilder: 24 | def __init__(self, root_module_type: ModuleType, compiler: ModuleCompiler | None = None) -> None: 25 | self._compiler: Final = compiler or ModuleCompiler() 26 | self._root_module_type: Final = root_module_type 27 | self._modules: dict[UUID, Module] = {} 28 | self._providers: list[BaseProvider] = [] 29 | 30 | self._metadata_cache: dict[ModuleType | DynamicModule, tuple[ModuleType, ModuleMetadata]] = {} 31 | 32 | def build(self) -> ModuleRegistry: 33 | modules, adjacency = self._collect_modules() 34 | root_module = self._register_modules(modules) 35 | return self._build_registry(root_module, adjacency) 36 | 37 | def _collect_modules(self) -> tuple[list[tuple[ModuleType, ModuleMetadata]], AdjacencyMatrix]: 38 | """Collect modules in post-order DFS.""" 39 | visited: set[UUID] = set() 40 | post_order: list[tuple[ModuleType, ModuleMetadata]] = [] 41 | adjacency: AdjacencyMatrix = defaultdict(OrderedDict) 42 | self._collect_modules_recursive(self._root_module_type, visited, post_order, adjacency) 43 | return post_order, adjacency 44 | 45 | def _collect_modules_recursive( 46 | self, 47 | current_type: ModuleType | DynamicModule, 48 | visited: set[UUID], 49 | post_order: list[tuple[ModuleType, ModuleMetadata]], 50 | adjacency: AdjacencyMatrix, 51 | ) -> None: 52 | type_, metadata = self._get_metadata(current_type) 53 | if metadata.id in visited: 54 | return 55 | 56 | adjacency[metadata.id][metadata.id] = type_.__name__ 57 | 58 | for imported in metadata.imports: 59 | imported_type, imported_metadata = self._get_metadata(imported) 60 | adjacency[metadata.id][imported_metadata.id] = imported_type.__name__ 61 | if imported_metadata.id not in visited: 62 | self._collect_modules_recursive(imported, visited, post_order, adjacency) 63 | 64 | post_order.append((type_, metadata)) 65 | visited.add(metadata.id) 66 | 67 | def _register_modules(self, post_order: list[tuple[ModuleType, ModuleMetadata]]) -> Module: 68 | for type_, metadata in post_order: 69 | if metadata.id in self._modules: 70 | continue 71 | 72 | if type_ is self._root_module_type: 73 | metadata.is_global = True 74 | 75 | module = Module(type_, metadata) 76 | 77 | self._modules[module.id] = module 78 | self._providers.append(module.provider) 79 | 80 | _, root_metadata = self._get_metadata(self._root_module_type) 81 | return self._modules[root_metadata.id] 82 | 83 | def _get_metadata(self, module_type: ModuleType | DynamicModule) -> tuple[ModuleType, ModuleMetadata]: 84 | """Get metadata with caching to avoid repeated extractions.""" 85 | if module_type not in self._metadata_cache: 86 | self._metadata_cache[module_type] = self._compiler.extract_metadata(module_type) 87 | return self._metadata_cache[module_type] 88 | 89 | def _build_registry(self, root_module: Module, adjacency: AdjacencyMatrix) -> ModuleRegistry: 90 | # Store topological order (post_order DFS) for event triggering 91 | return ModuleRegistry( 92 | compiler=self._compiler, 93 | modules=self._modules, 94 | providers=self._providers, 95 | root_module=root_module, 96 | adjacency=adjacency, 97 | ) 98 | -------------------------------------------------------------------------------- /src/waku/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/src/waku/py.typed -------------------------------------------------------------------------------- /src/waku/testing.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import contextmanager 4 | from itertools import chain 5 | from typing import TYPE_CHECKING, Protocol, cast 6 | 7 | from dishka import STRICT_VALIDATION, make_async_container 8 | 9 | from waku.di import DEFAULT_COMPONENT, AsyncContainer, BaseProvider 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Iterator 13 | 14 | from dishka.dependency_source import Factory 15 | from dishka.registry import Registry 16 | 17 | 18 | __all__ = ['override'] 19 | 20 | 21 | class _Overrideable(Protocol): 22 | override: bool 23 | 24 | 25 | @contextmanager 26 | def override(container: AsyncContainer, *providers: BaseProvider) -> Iterator[None]: 27 | """Temporarily override providers in an AsyncContainer for testing. 28 | 29 | Args: 30 | container: The container whose providers will be overridden. 31 | *providers: Providers to override in the container. 32 | 33 | Yields: 34 | None: Context in which the container uses the overridden providers. 35 | 36 | Example: 37 | ```python 38 | from waku import WakuFactory, module 39 | from waku.di import Scope, singleton 40 | from waku.testing import override 41 | 42 | 43 | class Service: ... 44 | 45 | 46 | class ServiceOverride(Service): ... 47 | 48 | 49 | with override(application.container, singleton(ServiceOverride, provided_type=Service)): 50 | service = await application.container.get(Service) 51 | assert isinstance(service, ServiceOverride) 52 | ``` 53 | """ 54 | for provider in providers: 55 | for factory in chain(provider.factories, provider.aliases): 56 | cast(_Overrideable, factory).override = True 57 | 58 | new_container = make_async_container( 59 | _container_provider(container), 60 | *providers, 61 | context=container._context, # noqa: SLF001 62 | start_scope=container.scope, 63 | validation_settings=STRICT_VALIDATION, 64 | ) 65 | 66 | _swap(container, new_container) 67 | yield 68 | _swap(new_container, container) 69 | 70 | 71 | def _container_provider(container: AsyncContainer) -> BaseProvider: 72 | container_provider = BaseProvider(component=DEFAULT_COMPONENT) 73 | container_provider.factories.extend(_extract_factories(container.registry)) 74 | for registry in container.child_registries: 75 | container_provider.factories.extend(_extract_factories(registry)) 76 | return container_provider 77 | 78 | 79 | def _extract_factories(registry: Registry) -> list[Factory]: 80 | return [factory for dep_key, factory in registry.factories.items() if dep_key.type_hint is not AsyncContainer] 81 | 82 | 83 | def _swap(c1: AsyncContainer, c2: AsyncContainer) -> None: 84 | for attr in type(c1).__slots__: 85 | tmp = getattr(c1, attr) 86 | setattr(c1, attr, getattr(c2, attr)) 87 | setattr(c2, attr, tmp) 88 | -------------------------------------------------------------------------------- /src/waku/validation/__init__.py: -------------------------------------------------------------------------------- 1 | from waku.validation._abc import ValidationRule 2 | from waku.validation._errors import ValidationError 3 | from waku.validation._extension import ValidationExtension 4 | 5 | __all__ = [ 6 | 'ValidationError', 7 | 'ValidationExtension', 8 | 'ValidationRule', 9 | ] 10 | -------------------------------------------------------------------------------- /src/waku/validation/_abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Protocol 4 | 5 | if TYPE_CHECKING: 6 | from waku.validation._errors import ValidationError 7 | from waku.validation._extension import ValidationContext 8 | 9 | __all__ = ['ValidationRule'] 10 | 11 | 12 | class ValidationRule(Protocol): 13 | def validate(self, context: ValidationContext) -> list[ValidationError]: ... 14 | -------------------------------------------------------------------------------- /src/waku/validation/_errors.py: -------------------------------------------------------------------------------- 1 | from waku.exceptions import WakuError 2 | 3 | __all__ = ['ValidationError'] 4 | 5 | 6 | class ValidationError(WakuError): 7 | pass 8 | -------------------------------------------------------------------------------- /src/waku/validation/_extension.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import warnings 4 | from dataclasses import dataclass 5 | from itertools import chain 6 | from typing import TYPE_CHECKING, Final 7 | 8 | from waku.extensions import AfterApplicationInit 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Sequence 12 | 13 | from waku.application import WakuApplication 14 | from waku.validation import ValidationRule 15 | from waku.validation._errors import ValidationError 16 | 17 | __all__ = [ 18 | 'ValidationContext', 19 | 'ValidationExtension', 20 | ] 21 | 22 | 23 | @dataclass(frozen=True, slots=True, kw_only=True) 24 | class ValidationContext: 25 | app: WakuApplication 26 | 27 | 28 | class ValidationExtension(AfterApplicationInit): 29 | __slots__ = ('rules', 'strict') 30 | 31 | def __init__(self, rules: Sequence[ValidationRule], *, strict: bool = True) -> None: 32 | self.rules = rules 33 | self.strict: Final = strict 34 | 35 | async def after_app_init(self, app: WakuApplication) -> None: 36 | context = ValidationContext(app=app) 37 | 38 | errors_chain = chain.from_iterable(rule.validate(context) for rule in self.rules) 39 | if errors := list(errors_chain): 40 | self._raise(errors) 41 | 42 | def _raise(self, errors: list[ValidationError]) -> None: 43 | if self.strict: 44 | msg = 'Validation error' 45 | raise ExceptionGroup(msg, errors) 46 | 47 | for error in errors: 48 | warnings.warn(str(error), stacklevel=3) 49 | -------------------------------------------------------------------------------- /src/waku/validation/rules/__init__.py: -------------------------------------------------------------------------------- 1 | from waku.validation.rules.dependency_accessible import ( 2 | DependenciesAccessibleRule, 3 | DependencyInaccessibleError, 4 | ) 5 | 6 | __all__ = [ 7 | 'DependenciesAccessibleRule', 8 | 'DependencyInaccessibleError', 9 | ] 10 | -------------------------------------------------------------------------------- /src/waku/validation/rules/_cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import OrderedDict 4 | from typing import Generic, TypeVar 5 | 6 | _T = TypeVar('_T') 7 | 8 | 9 | class LRUCache(Generic[_T]): 10 | """LRU cache for module type data with controlled size.""" 11 | 12 | __slots__ = ('_cache', '_max_size') 13 | 14 | def __init__(self, max_size: int = 1000) -> None: 15 | self._cache: OrderedDict[str, _T] = OrderedDict() 16 | self._max_size = max_size 17 | 18 | def get(self, key: str) -> _T | None: 19 | """Get value from cache, moving it to end if found.""" 20 | if key not in self._cache: 21 | return None 22 | self._cache.move_to_end(key) 23 | return self._cache[key] 24 | 25 | def put(self, key: str, value: _T) -> None: 26 | """Add/update value in cache, removing the oldest if at capacity.""" 27 | self._cache[key] = value 28 | self._cache.move_to_end(key) 29 | if len(self._cache) > self._max_size: 30 | self._cache.popitem(last=False) 31 | 32 | def clear(self) -> None: 33 | """Clear all cached data.""" 34 | self._cache.clear() 35 | 36 | def __len__(self) -> int: 37 | """Return the number of items in the cache.""" 38 | return len(self._cache) 39 | -------------------------------------------------------------------------------- /src/waku/validation/rules/_types_extractor.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | from itertools import chain 5 | from typing import TYPE_CHECKING, Protocol, cast 6 | 7 | from waku.modules import HasModuleMetadata, Module, ModuleRegistry 8 | 9 | if TYPE_CHECKING: 10 | from uuid import UUID 11 | 12 | from dishka.entities.key import DependencyKey 13 | 14 | from waku.validation.rules._cache import LRUCache 15 | 16 | 17 | class ModuleTypesExtractor: 18 | __slots__ = ('_cache',) 19 | 20 | def __init__(self, cache: LRUCache[set[type[object]]]) -> None: 21 | self._cache = cache 22 | 23 | def get_provided_types(self, module: Module) -> set[type[object]]: 24 | cache_key = f'provided_{module.id}' 25 | cached = self._cache.get(cache_key) 26 | if cached is not None: 27 | return cached 28 | 29 | module_provider = module.provider 30 | result: set[type[object]] = { 31 | cast(_HasProvidesAttr, dep).provides.type_hint 32 | for dep in chain(module_provider.factories, module_provider.aliases, module_provider.decorators) 33 | } 34 | self._cache.put(cache_key, result) 35 | return result 36 | 37 | def get_context_vars(self, module: Module) -> set[type[object]]: 38 | cache_key = f'context_{module.id}' 39 | cached = self._cache.get(cache_key) 40 | if cached is not None: 41 | return cached 42 | 43 | result: set[type[object]] = {context_var.provides.type_hint for context_var in module.provider.context_vars} 44 | self._cache.put(cache_key, result) 45 | return result 46 | 47 | def get_reexported_types( 48 | self, 49 | module: Module, 50 | registry: ModuleRegistry, 51 | ) -> set[type[object]]: 52 | cache_key = f'reexported_{module.id}' 53 | cached = self._cache.get(cache_key) 54 | if cached is not None: 55 | return cached 56 | 57 | result: set[type[object]] = set() 58 | visited: set[UUID] = set() 59 | modules_to_process: deque[Module] = deque([module]) 60 | 61 | while modules_to_process: 62 | current_module = modules_to_process.popleft() 63 | if current_module.id in visited: 64 | continue 65 | 66 | visited.add(current_module.id) 67 | 68 | for export in module.exports: 69 | if not isinstance(export, HasModuleMetadata): 70 | continue 71 | 72 | exported_module = registry.get(export) # type: ignore[unreachable] 73 | result.update(exp for exp in exported_module.exports if isinstance(exp, type)) 74 | modules_to_process.append(exported_module) 75 | 76 | self._cache.put(cache_key, result) 77 | return result 78 | 79 | 80 | class _HasProvidesAttr(Protocol): 81 | """Protocol for objects with provides attribute.""" 82 | 83 | provides: DependencyKey 84 | -------------------------------------------------------------------------------- /src/waku/validation/rules/dependency_accessible.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import cached_property 4 | from itertools import chain 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from dishka.entities.factory_type import FactoryType 8 | from typing_extensions import override 9 | 10 | from waku.di import Scope 11 | from waku.validation import ValidationError, ValidationRule 12 | from waku.validation.rules._cache import LRUCache 13 | from waku.validation.rules._types_extractor import ModuleTypesExtractor 14 | 15 | if TYPE_CHECKING: 16 | from collections.abc import Iterable, Sequence 17 | 18 | from waku.modules import Module 19 | from waku.validation._extension import ValidationContext 20 | 21 | 22 | __all__ = [ 23 | 'DependenciesAccessibleRule', 24 | 'DependencyInaccessibleError', 25 | ] 26 | 27 | 28 | class DependencyInaccessibleError(ValidationError): 29 | """Error indicating a dependency is not accessible to a provider/module.""" 30 | 31 | def __init__( 32 | self, 33 | required_type: type[object], 34 | required_by: object, 35 | from_module: Module, 36 | ) -> None: 37 | self.required_type = required_type 38 | self.required_by = required_by 39 | self.from_module = from_module 40 | super().__init__(str(self)) 41 | 42 | def __str__(self) -> str: 43 | msg = [ 44 | f'Dependency Error: "{self.required_type!r}" is not accessible', 45 | f'Required by: "{self.required_by!r}"', 46 | f'In module: "{self.from_module!r}"', 47 | '', 48 | 'To resolve this issue, either:', 49 | f'1. Export "{self.required_type!r}" from a module that provides it and add that module to "{self.from_module!r}" imports', 50 | f'2. Make the module that provides "{self.required_type!r}" global by setting is_global=True', 51 | f'3. Move the dependency to a module that has access to "{self.required_type!r}"', 52 | '', 53 | 'Note: Dependencies can only be accessed from:', 54 | '- The same module that provides them', 55 | '- Modules that import the module that provides and exports it', 56 | '- Global modules', 57 | ] 58 | return '\n'.join(msg) 59 | 60 | 61 | class DependencyAccessChecker: 62 | """Handles dependency accessibility checks between modules.""" 63 | 64 | def __init__( 65 | self, 66 | modules: list[Module], 67 | context: ValidationContext, 68 | types_extractor: ModuleTypesExtractor, 69 | ) -> None: 70 | self._modules = modules 71 | self._context = context 72 | self._registry = context.app.registry 73 | self._type_provider = types_extractor 74 | 75 | def find_inaccessible_dependencies(self, dependencies: Sequence[Any], module: Module) -> Iterable[type[object]]: 76 | """Find dependencies that are not accessible to a module.""" 77 | return ( 78 | dependency.type_hint for dependency in dependencies if not self._is_accessible(dependency.type_hint, module) 79 | ) 80 | 81 | @cached_property 82 | def _global_providers(self) -> set[type[object]]: 83 | return self._build_global_providers() 84 | 85 | def _build_global_providers(self) -> set[type[object]]: 86 | """Build a set of all globally accessible types.""" 87 | global_module_types = { 88 | provided_type 89 | for module in self._modules 90 | for provided_type in chain( 91 | self._type_provider.get_provided_types(module), 92 | self._type_provider.get_reexported_types(module, self._registry), 93 | ) 94 | if module.is_global 95 | } 96 | 97 | global_context_types = { 98 | dep.type_hint 99 | for dep, factory in self._context.app.container.registry.factories.items() 100 | if (factory.scope is Scope.APP and factory.type is FactoryType.CONTEXT) 101 | } 102 | 103 | return global_module_types | global_context_types 104 | 105 | def _is_accessible(self, required_type: type[object], module: Module) -> bool: 106 | """Check if a type is accessible to a module. 107 | 108 | A type is accessible if: 109 | 1. It is provided by the module itself 110 | 2. It is provided by a global module 111 | 3. It is provided and exported by an imported module 112 | 4. It is provided by a module that is re-exported by an imported module 113 | """ 114 | # Check if type is provided by a global module 115 | if required_type in self._global_providers: 116 | return True 117 | # Check if type is provided by the module itself 118 | if required_type in self._type_provider.get_provided_types(module): 119 | return True 120 | # Check if type is provided by application or request container context 121 | if required_type in self._type_provider.get_context_vars(module): 122 | return True 123 | # Check imported modules 124 | for imported in module.imports: 125 | imported_module = self._registry.get(imported) 126 | # Check if type is directly provided and exported by the imported module 127 | if ( 128 | required_type in self._type_provider.get_provided_types(imported_module) 129 | and required_type in imported_module.exports 130 | ): 131 | return True 132 | # Check if type is provided by a module that is re-exported by an imported module 133 | if self._type_provider.get_reexported_types(imported_module, self._registry): 134 | return True 135 | 136 | return False 137 | 138 | 139 | class DependenciesAccessibleRule(ValidationRule): 140 | """Validates that all dependencies required by providers are accessible.""" 141 | 142 | __slots__ = ('_cache', '_types_extractor') 143 | 144 | def __init__(self, cache_size: int = 1000) -> None: 145 | self._cache = LRUCache[set[type[object]]](cache_size) 146 | self._types_extractor = ModuleTypesExtractor(self._cache) 147 | 148 | @override 149 | def validate(self, context: ValidationContext) -> list[ValidationError]: 150 | self._cache.clear() # Clear cache before validation 151 | 152 | registry = context.app.registry 153 | modules = list(registry.modules) 154 | 155 | checker = DependencyAccessChecker(modules, context, self._types_extractor) 156 | errors: list[ValidationError] = [] 157 | 158 | for module in modules: 159 | module_provider = module.provider 160 | for factory in module_provider.factories: 161 | inaccessible_deps = checker.find_inaccessible_dependencies( 162 | dependencies=factory.dependencies, 163 | module=module, 164 | ) 165 | errors.extend( 166 | DependencyInaccessibleError( 167 | required_type=dep_type, 168 | required_by=factory.source, 169 | from_module=module, 170 | ) 171 | for dep_type in inaccessible_deps 172 | ) 173 | 174 | return errors 175 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/tests/__init__.py -------------------------------------------------------------------------------- /tests/application/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/tests/application/__init__.py -------------------------------------------------------------------------------- /tests/application/test_lifecycle.py: -------------------------------------------------------------------------------- 1 | """Tests for application lifecycle management.""" 2 | 3 | from dataclasses import dataclass 4 | 5 | from waku import WakuFactory 6 | 7 | from tests.module_utils import create_basic_module 8 | 9 | 10 | @dataclass 11 | class MockLifespanManager: 12 | """A mock lifespan manager for testing.""" 13 | 14 | entered: bool = False 15 | exited: bool = False 16 | 17 | async def __aenter__(self) -> None: 18 | self.entered = True 19 | 20 | async def __aexit__(self, *_: object) -> None: 21 | self.exited = True 22 | 23 | 24 | async def test_application_lifespan_manager_execution() -> None: 25 | """Application should execute lifespan managers in order and handle their lifecycle correctly.""" 26 | manager_1 = MockLifespanManager() 27 | manager_2 = MockLifespanManager() 28 | 29 | AppModule = create_basic_module(name='AppModule') 30 | 31 | application = WakuFactory(AppModule, lifespan=[manager_1, manager_2]).create() 32 | 33 | async with application: 34 | assert manager_1.entered 35 | assert manager_2.entered 36 | assert not manager_1.exited 37 | assert not manager_2.exited 38 | 39 | assert manager_1.exited 40 | assert manager_2.exited # type: ignore[unreachable] 41 | -------------------------------------------------------------------------------- /tests/application/test_module_registration.py: -------------------------------------------------------------------------------- 1 | from waku import WakuFactory 2 | from waku.di import scoped, singleton 3 | 4 | from tests.data import A, C 5 | from tests.module_utils import create_basic_module 6 | 7 | 8 | async def test_module_registration_with_dependencies() -> None: 9 | """Modules should be properly registered with their dependencies and providers.""" 10 | ModuleA = create_basic_module( 11 | providers=[scoped(A)], 12 | exports=[A], 13 | name='ModuleA', 14 | ) 15 | ModuleB = create_basic_module( 16 | providers=[singleton(C)], 17 | imports=[ModuleA], 18 | name='ModuleB', 19 | ) 20 | AppModule = create_basic_module( 21 | imports=[ModuleA, ModuleB], 22 | name='AppModule', 23 | ) 24 | 25 | application = WakuFactory(AppModule).create() 26 | 27 | async with application, application.container() as request_container: 28 | await request_container.get(A) 29 | await application.container.get(C) 30 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Test configuration and shared fixtures.""" 2 | 3 | import sys 4 | from typing import cast 5 | 6 | import pytest 7 | 8 | 9 | @pytest.fixture( 10 | scope='session', 11 | params=[ 12 | pytest.param( 13 | ('asyncio', {'use_uvloop': True}), 14 | id='asyncio+uvloop', 15 | marks=pytest.mark.skipif( 16 | sys.platform.startswith('win'), 17 | reason='uvloop does not support Windows', 18 | ), 19 | ), 20 | pytest.param(('asyncio', {'use_uvloop': False}), id='asyncio'), 21 | pytest.param(('trio', {'restrict_keyboard_interrupt_to_checkpoints': True}), id='trio'), 22 | ], 23 | autouse=True, 24 | ) 25 | def anyio_backend(request: pytest.FixtureRequest) -> tuple[str, dict[str, object]]: 26 | return cast(tuple[str, dict[str, object]], request.param) 27 | -------------------------------------------------------------------------------- /tests/data.py: -------------------------------------------------------------------------------- 1 | """Common test data classes used across tests.""" 2 | 3 | from dataclasses import dataclass 4 | from typing import NewType 5 | 6 | from waku import Module 7 | from waku.di import BaseProvider 8 | from waku.extensions import OnModuleConfigure, OnModuleDestroy, OnModuleInit 9 | from waku.modules import ModuleMetadata 10 | 11 | 12 | # Simple test services 13 | @dataclass 14 | class Service: 15 | """A simple service for testing.""" 16 | 17 | 18 | @dataclass 19 | class DependentService: 20 | """A service that depends on another service.""" 21 | 22 | service: Service 23 | 24 | 25 | @dataclass 26 | class RequestContext: 27 | """A simple request context for testing.""" 28 | 29 | user_id: int 30 | 31 | 32 | @dataclass 33 | class UserService: 34 | """A user service for testing.""" 35 | 36 | user_id: int 37 | 38 | 39 | # Simple A, B, C services commonly used in dependency tests 40 | @dataclass 41 | class A: 42 | """Service A for testing dependencies.""" 43 | 44 | 45 | AAliasType = NewType('AAliasType', A) 46 | 47 | 48 | @dataclass 49 | class B: 50 | """Service B for testing dependencies.""" 51 | 52 | a: A 53 | 54 | 55 | @dataclass 56 | class C: 57 | """Service C for testing dependencies.""" 58 | 59 | 60 | @dataclass 61 | class X: 62 | """Service X for testing dependencies.""" 63 | 64 | 65 | @dataclass 66 | class Y: 67 | """Service Y for testing dependencies.""" 68 | 69 | 70 | @dataclass 71 | class Z: 72 | """Service Z for testing dependencies.""" 73 | 74 | x: X 75 | y: Y 76 | 77 | 78 | # Common module extensions 79 | class OnInitExt(OnModuleInit): 80 | """Extension that tracks module initialization.""" 81 | 82 | def __init__(self, calls: list[tuple[type, type]]) -> None: 83 | self.calls = calls 84 | 85 | async def on_module_init(self, module: Module) -> None: 86 | self.calls.append((module.target, type(self))) 87 | 88 | 89 | class OnDestroyExt(OnModuleDestroy): 90 | """Extension that tracks module destruction.""" 91 | 92 | def __init__(self, calls: list[tuple[type, type]]) -> None: 93 | self.calls = calls 94 | 95 | async def on_module_destroy(self, module: Module) -> None: 96 | self.calls.append((module.target, type(self))) 97 | 98 | 99 | class AddDepOnConfigure(OnModuleConfigure): 100 | """Extension that adds a dependency during module configuration.""" 101 | 102 | def __init__(self, provider: BaseProvider) -> None: 103 | self.provider = provider 104 | 105 | def on_module_configure(self, metadata: ModuleMetadata) -> None: 106 | metadata.providers.append(self.provider) 107 | -------------------------------------------------------------------------------- /tests/di/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/tests/di/__init__.py -------------------------------------------------------------------------------- /tests/di/test_providers.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from waku import WakuFactory 4 | from waku.di import object_, scoped 5 | 6 | from tests.data import UserService 7 | from tests.module_utils import create_basic_module 8 | 9 | 10 | async def test_object_provider_instance_identity() -> None: 11 | """Object provider should maintain instance identity and values.""" 12 | instance = UserService(user_id=1337) 13 | 14 | AppModule = create_basic_module( 15 | providers=[object_(instance, provided_type=UserService)], 16 | name='AppModule', 17 | ) 18 | 19 | app = WakuFactory(AppModule).create() 20 | 21 | async with app, app.container() as container: 22 | result = await container.get(UserService) 23 | assert result is instance 24 | assert result.user_id == 1337 25 | 26 | 27 | async def test_provider_with_base_class_type() -> None: 28 | """Provider should support providing implementation through base class type.""" 29 | 30 | class BaseService(Protocol): 31 | pass 32 | 33 | class ConcreteService(BaseService): 34 | pass 35 | 36 | AppModule = create_basic_module( 37 | providers=[scoped(ConcreteService, provided_type=BaseService)], 38 | name='AppModule', 39 | ) 40 | 41 | app = WakuFactory(AppModule).create() 42 | 43 | async with app, app.container() as container: 44 | result = await container.get(BaseService) 45 | assert isinstance(result, ConcreteService) 46 | -------------------------------------------------------------------------------- /tests/di/test_scopes.py: -------------------------------------------------------------------------------- 1 | """Tests for dependency injection scopes.""" 2 | 3 | from waku import WakuFactory 4 | from waku.di import contextual, scoped, singleton, transient 5 | 6 | from tests.data import DependentService, RequestContext, Service, UserService 7 | from tests.module_utils import create_basic_module 8 | 9 | 10 | async def test_provider_scope_behavior() -> None: 11 | """Different provider scopes should behave according to their lifecycle rules.""" 12 | AppModule = create_basic_module( 13 | providers=[ 14 | scoped(Service), 15 | transient(lambda: UserService(user_id=2), provided_type=UserService), 16 | ], 17 | name='AppModule', 18 | ) 19 | 20 | app = WakuFactory(AppModule).create() 21 | 22 | async with app, app.container() as container: 23 | # Test transient - new instance each time 24 | service1 = await container.get(UserService) 25 | service2 = await container.get(UserService) 26 | assert service1 is not service2 27 | assert service1.user_id == 2 28 | assert service2.user_id == 2 29 | 30 | # Test scoped - same instance per request 31 | dep1 = await container.get(Service) 32 | dep2 = await container.get(Service) 33 | assert dep1 is dep2 34 | 35 | 36 | async def test_contextual_provider_instance_resolution() -> None: 37 | """Contextual provider should resolve instances from provided context.""" 38 | AppModule = create_basic_module( 39 | providers=[contextual(UserService)], 40 | name='AppModule', 41 | ) 42 | 43 | app = WakuFactory(AppModule).create() 44 | 45 | async with app: 46 | service_instance = UserService(user_id=4) 47 | context = {UserService: service_instance} 48 | async with app.container(context=context) as container: 49 | result = await container.get(UserService) 50 | assert result is service_instance 51 | assert result.user_id == 4 52 | 53 | 54 | def _create_user_service(ctx: RequestContext) -> UserService: 55 | return UserService(ctx.user_id) 56 | 57 | 58 | async def test_request_context_provider_isolation() -> None: 59 | """Request context providers should maintain isolation between different requests.""" 60 | AppModule = create_basic_module( 61 | providers=[ 62 | contextual(RequestContext), 63 | scoped(_create_user_service, provided_type=UserService), 64 | ], 65 | name='AppModule', 66 | ) 67 | 68 | app = WakuFactory(AppModule).create() 69 | 70 | async with app: 71 | # First request 72 | context1 = {RequestContext: RequestContext(user_id=1)} 73 | async with app.container(context=context1) as container1: 74 | user1 = await container1.get(UserService) 75 | assert user1.user_id == 1 76 | 77 | # Second request with different context 78 | context2 = {RequestContext: RequestContext(user_id=2)} 79 | async with app.container(context=context2) as container2: 80 | user2 = await container2.get(UserService) 81 | assert user2.user_id == 2 82 | 83 | 84 | async def test_injected_dependency_instance_sharing() -> None: 85 | """Injected dependencies should share the same instance within a scope.""" 86 | AppModule = create_basic_module( 87 | providers=[ 88 | singleton(lambda: 1, provided_type=int), 89 | scoped(Service), 90 | scoped(DependentService), 91 | ], 92 | name='AppModule', 93 | ) 94 | 95 | app = WakuFactory(AppModule).create() 96 | 97 | async with app, app.container() as container: 98 | service = await container.get(Service) 99 | dep = await container.get(DependentService) 100 | assert dep.service is service 101 | 102 | 103 | async def test_request_scope_dependency_injection() -> None: 104 | """Test that request scoped dependencies are properly injected.""" 105 | ServiceModule = create_basic_module( 106 | providers=[scoped(Service)], 107 | exports=[Service], 108 | name='ServiceModule', 109 | ) 110 | 111 | DependentModule = create_basic_module( 112 | providers=[scoped(DependentService)], 113 | imports=[ServiceModule], 114 | name='DependentModule', 115 | ) 116 | 117 | AppModule = create_basic_module( 118 | imports=[ServiceModule, DependentModule], 119 | name='AppModule', 120 | ) 121 | 122 | application = WakuFactory(AppModule).create() 123 | 124 | async with application, application.container(context={int: 42}) as request_container: 125 | service = await request_container.get(Service) 126 | dependent = await request_container.get(DependentService) 127 | 128 | assert dependent.service is service 129 | 130 | 131 | async def test_request_scope_with_context() -> None: 132 | """Test that request scoped dependencies have access to request context.""" 133 | UserModule = create_basic_module( 134 | providers=[scoped(UserService), contextual(int)], 135 | name='ServiceModule', 136 | ) 137 | 138 | AppModule = create_basic_module( 139 | imports=[UserModule], 140 | name='AppModule', 141 | ) 142 | 143 | application = WakuFactory(AppModule).create() 144 | 145 | async with application, application.container(context={int: 42}) as request_container: 146 | user_service = await request_container.get(UserService) 147 | assert user_service.user_id == 42 148 | -------------------------------------------------------------------------------- /tests/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/tests/extensions/__init__.py -------------------------------------------------------------------------------- /tests/extensions/test_application_extensions.py: -------------------------------------------------------------------------------- 1 | from pytest_mock import MockerFixture 2 | 3 | from waku import WakuApplication, WakuFactory 4 | from waku.extensions import AfterApplicationInit, OnApplicationInit, OnModuleConfigure, OnModuleInit 5 | from waku.modules import Module, ModuleMetadata 6 | 7 | from tests.module_utils import create_basic_module 8 | 9 | 10 | async def test_module_init_extension_lifecycle(mocker: MockerFixture) -> None: 11 | """Module extensions should be called in correct order during module initialization.""" 12 | on_module_configure_mock = mocker.stub() 13 | on_module_init_mock = mocker.async_stub() 14 | 15 | class ModuleOnConfigureExt(OnModuleConfigure): 16 | def on_module_configure(self, metadata: ModuleMetadata) -> None: # noqa: PLR6301 17 | on_module_configure_mock(metadata) 18 | 19 | class ModuleOnInitExt(OnModuleInit): 20 | async def on_module_init(self, module: Module) -> None: # noqa: PLR6301 21 | await on_module_init_mock(module) 22 | 23 | AppModule = create_basic_module( 24 | extensions=[ 25 | ModuleOnConfigureExt(), 26 | ModuleOnInitExt(), 27 | ], 28 | name='AppModule', 29 | ) 30 | 31 | application = WakuFactory(AppModule).create() 32 | await application.initialize() 33 | 34 | assert on_module_configure_mock.call_count == 1 35 | assert isinstance(on_module_configure_mock.call_args[0][0], ModuleMetadata) 36 | assert on_module_init_mock.call_count == 1 37 | assert isinstance(on_module_init_mock.call_args[0][0], Module) 38 | 39 | 40 | async def test_application_init_extensions_single_call(mocker: MockerFixture) -> None: 41 | """Application init extensions should be called exactly once even with multiple initializations.""" 42 | on_app_init_mock = mocker.async_stub() 43 | after_app_init_mock = mocker.async_stub() 44 | 45 | class AppOnInitExt(OnApplicationInit): 46 | async def on_app_init(self, app: WakuApplication) -> None: # noqa: PLR6301 47 | await on_app_init_mock(app) 48 | 49 | class AppAfterInitExt(AfterApplicationInit): 50 | async def after_app_init(self, app: WakuApplication) -> None: # noqa: PLR6301 51 | await after_app_init_mock(app) 52 | 53 | application = WakuFactory( 54 | create_basic_module(name='AppModule'), 55 | extensions=[ 56 | AppOnInitExt(), 57 | AppAfterInitExt(), 58 | ], 59 | ).create() 60 | 61 | # Should be called once for the application initialization 62 | await application.initialize() 63 | await application.initialize() 64 | 65 | assert on_app_init_mock.call_count == 1 66 | assert isinstance(on_app_init_mock.call_args[0][0], WakuApplication) 67 | assert after_app_init_mock.call_count == 1 68 | -------------------------------------------------------------------------------- /tests/extensions/test_module_extensions.py: -------------------------------------------------------------------------------- 1 | """Tests for module extensions.""" 2 | 3 | from waku import WakuFactory 4 | from waku.di import scoped 5 | 6 | from tests.data import A, AddDepOnConfigure, OnDestroyExt, OnInitExt 7 | from tests.module_utils import create_basic_module 8 | 9 | 10 | async def test_module_extensions_initialization_and_destruction_order() -> None: 11 | """Module extensions should be called in correct order during initialization and destruction.""" 12 | calls: list[tuple[type, type]] = [] 13 | 14 | GlobalModule = create_basic_module( 15 | extensions=[OnInitExt(calls), OnDestroyExt(calls)], 16 | name='GlobalModule', 17 | is_global=True, 18 | ) 19 | 20 | DatabaseModule = create_basic_module( 21 | extensions=[OnInitExt(calls), OnDestroyExt(calls)], 22 | name='DatabaseModule', 23 | ) 24 | 25 | UsersModule = create_basic_module( 26 | imports=[DatabaseModule], 27 | extensions=[OnInitExt(calls), OnDestroyExt(calls)], 28 | name='UsersModule', 29 | ) 30 | 31 | AuthModule = create_basic_module( 32 | imports=[UsersModule], 33 | extensions=[OnInitExt(calls), OnDestroyExt(calls)], 34 | name='AuthModule', 35 | ) 36 | 37 | AppModule = create_basic_module( 38 | imports=[GlobalModule, DatabaseModule, UsersModule, AuthModule], 39 | extensions=[OnInitExt(calls), OnDestroyExt(calls)], 40 | name='AppModule', 41 | ) 42 | 43 | application = WakuFactory(AppModule).create() 44 | 45 | async with application: 46 | pass 47 | 48 | assert calls == [ 49 | (GlobalModule, OnInitExt), 50 | (DatabaseModule, OnInitExt), 51 | (UsersModule, OnInitExt), 52 | (AuthModule, OnInitExt), 53 | (AppModule, OnInitExt), 54 | (AppModule, OnDestroyExt), 55 | (AuthModule, OnDestroyExt), 56 | (UsersModule, OnDestroyExt), 57 | (DatabaseModule, OnDestroyExt), 58 | (GlobalModule, OnDestroyExt), 59 | ] 60 | 61 | excepted_modules_order = [ 62 | GlobalModule, 63 | DatabaseModule, 64 | UsersModule, 65 | AuthModule, 66 | AppModule, 67 | ] 68 | 69 | for mod, expected_type in zip(application.registry.modules, excepted_modules_order, strict=True): 70 | assert mod.target is expected_type 71 | 72 | 73 | def test_module_configure_extension_idempotency() -> None: 74 | """Module configuration should be applied only once regardless of multiple factory creations.""" 75 | SomeModule = create_basic_module( 76 | extensions=[AddDepOnConfigure(scoped(A))], 77 | name='SomeModule', 78 | ) 79 | AppModule = create_basic_module( 80 | imports=[SomeModule], 81 | name='AppModule', 82 | ) 83 | 84 | WakuFactory(AppModule).create() 85 | application = WakuFactory(AppModule).create() 86 | 87 | assert len(application.registry.get(SomeModule).providers) == 1 88 | -------------------------------------------------------------------------------- /tests/extensions/test_registry.py: -------------------------------------------------------------------------------- 1 | # mypy: disable-error-code="type-abstract" 2 | from __future__ import annotations 3 | 4 | from typing import TYPE_CHECKING 5 | 6 | from waku.extensions import ( 7 | AfterApplicationInit, 8 | OnApplicationInit, 9 | OnApplicationShutdown, 10 | OnModuleConfigure, 11 | OnModuleDestroy, 12 | OnModuleInit, 13 | ) 14 | from waku.extensions.registry import ExtensionRegistry 15 | 16 | from tests.module_utils import create_basic_module 17 | 18 | if TYPE_CHECKING: 19 | from waku import WakuApplication 20 | from waku.modules import Module, ModuleMetadata 21 | 22 | 23 | class OnApplicationInitExt(OnApplicationInit): 24 | async def on_app_init(self, app: WakuApplication) -> None: 25 | pass 26 | 27 | 28 | class AfterApplicationInitExt(AfterApplicationInit): 29 | async def after_app_init(self, app: WakuApplication) -> None: 30 | pass 31 | 32 | 33 | class OnApplicationShutdownExt(OnApplicationShutdown): 34 | async def on_app_shutdown(self, app: WakuApplication) -> None: 35 | pass 36 | 37 | 38 | class OnModuleConfigureExt(OnModuleConfigure): 39 | def on_module_configure(self, metadata: ModuleMetadata) -> None: 40 | pass 41 | 42 | 43 | class OnModuleInitExt(OnModuleInit): 44 | async def on_module_init(self, module: Module) -> None: 45 | pass 46 | 47 | 48 | class OnModuleDestroyExt(OnModuleDestroy): 49 | async def on_module_destroy(self, module: Module) -> None: 50 | pass 51 | 52 | 53 | def test_register_application_extension() -> None: 54 | """Should register application extensions and retrieve them by type.""" 55 | # Arrange 56 | registry = ExtensionRegistry() 57 | 58 | on_init_ext = OnApplicationInitExt() 59 | after_init_ext = AfterApplicationInitExt() 60 | shutdown_ext = OnApplicationShutdownExt() 61 | 62 | registry.register_application_extension(on_init_ext) 63 | registry.register_application_extension(after_init_ext) 64 | registry.register_application_extension(shutdown_ext) 65 | 66 | # Act & Assert 67 | on_init_exts = registry.get_application_extensions(OnApplicationInit) 68 | assert len(on_init_exts) == 1 69 | assert on_init_exts[0] is on_init_ext 70 | 71 | after_init_exts = registry.get_application_extensions(AfterApplicationInit) 72 | assert len(after_init_exts) == 1 73 | assert after_init_exts[0] is after_init_ext 74 | 75 | shutdown_exts = registry.get_application_extensions(OnApplicationShutdown) 76 | assert len(shutdown_exts) == 1 77 | assert shutdown_exts[0] is shutdown_ext 78 | 79 | 80 | def test_get_multi_protocol_app_extensions() -> None: 81 | """Should return application extensions that implement multiple protocols.""" 82 | 83 | # Arrange 84 | class MultiAppExt(OnApplicationInit, AfterApplicationInit): 85 | async def on_app_init(self, app: WakuApplication) -> None: 86 | pass 87 | 88 | async def after_app_init(self, app: WakuApplication) -> None: 89 | pass 90 | 91 | registry = ExtensionRegistry() 92 | multi_ext = MultiAppExt() 93 | registry.register_application_extension(multi_ext) 94 | 95 | # Act & Assert 96 | assert registry.get_application_extensions(OnApplicationInit) == [multi_ext] 97 | assert registry.get_application_extensions(AfterApplicationInit) == [multi_ext] 98 | 99 | 100 | def test_get_application_extensions_no_match() -> None: 101 | """Should return empty list when no extensions match the protocol.""" 102 | # Arrange 103 | registry_empty = ExtensionRegistry() 104 | # Act & Assert 105 | result = registry_empty.get_application_extensions(OnApplicationShutdown) 106 | assert result == [] 107 | 108 | 109 | def test_register_module_extension_with_target() -> None: 110 | """Should register module extensions with targets and retrieve them appropriately.""" 111 | # Arrange 112 | registry = ExtensionRegistry() 113 | 114 | ModuleA = create_basic_module(name='ModuleA') 115 | ModuleB = create_basic_module(name='ModuleB') 116 | 117 | module_init_ext1 = OnModuleInitExt() 118 | module_init_ext2 = OnModuleInitExt() 119 | module_destroy_ext = OnModuleDestroyExt() 120 | 121 | registry.register_module_extension(ModuleA, module_init_ext1) 122 | registry.register_module_extension(ModuleB, module_init_ext2) 123 | registry.register_module_extension(ModuleA, module_destroy_ext) 124 | 125 | # Act & Assert 126 | module_a_init_exts = registry.get_module_extensions(ModuleA, OnModuleInit) 127 | assert len(module_a_init_exts) == 1 128 | assert module_a_init_exts[0] is module_init_ext1 129 | 130 | 131 | def test_get_multi_protocol_module_extensions() -> None: 132 | """Should return module extensions that implement multiple protocols.""" 133 | 134 | # Arrange 135 | class MultiModuleExt(OnModuleInit, OnModuleDestroy): 136 | async def on_module_init(self, module: Module) -> None: 137 | pass 138 | 139 | async def on_module_destroy(self, module: Module) -> None: 140 | pass 141 | 142 | registry = ExtensionRegistry() 143 | multi_ext = MultiModuleExt() 144 | SomeModule = create_basic_module(name='SomeModule') 145 | registry.register_module_extension(SomeModule, multi_ext) 146 | 147 | # Act & Assert 148 | assert registry.get_module_extensions(SomeModule, OnModuleInit) == [multi_ext] 149 | assert registry.get_module_extensions(SomeModule, OnModuleDestroy) == [multi_ext] 150 | 151 | 152 | def test_get_module_extensions_no_match() -> None: 153 | """Should return empty list when module has extensions but none match the queried protocol.""" 154 | # Arrange 155 | registry = ExtensionRegistry() 156 | SomeModule = create_basic_module(name='SomeModule') 157 | registry.register_module_extension(SomeModule, OnModuleInitExt()) 158 | 159 | # Act & Assert 160 | result = registry.get_module_extensions(SomeModule, OnModuleDestroy) 161 | assert result == [] 162 | -------------------------------------------------------------------------------- /tests/module_utils.py: -------------------------------------------------------------------------------- 1 | """Common module patterns used across tests.""" 2 | 3 | from collections.abc import Sequence 4 | from typing import Any 5 | 6 | from waku import module 7 | from waku.extensions import ModuleExtension 8 | from waku.modules import DynamicModule, ModuleType 9 | 10 | 11 | def create_basic_module( 12 | *, 13 | providers: Sequence[Any] | None = None, 14 | exports: Sequence[Any] | None = None, 15 | imports: Sequence[Any] | None = None, 16 | name: str | None = None, 17 | extensions: Sequence[ModuleExtension] | None = None, 18 | is_global: bool = False, 19 | ) -> ModuleType: 20 | """Create a basic module with given configuration.""" 21 | cls: ModuleType = module( 22 | providers=list(providers or []), 23 | exports=list(exports or []), 24 | imports=list(imports or []), 25 | extensions=list(extensions or []), 26 | is_global=is_global, 27 | )(type(name or 'BasicModule', (object,), {})) 28 | 29 | return cls 30 | 31 | 32 | def create_dynamic_module( 33 | *, 34 | providers: Sequence[Any] | None = None, 35 | exports: Sequence[Any] | None = None, 36 | imports: Sequence[Any] | None = None, 37 | extensions: Sequence[Any] | None = None, 38 | name: str | None = None, 39 | ) -> DynamicModule: 40 | """Create a dynamic module with given configuration.""" 41 | 42 | @module() 43 | class DynamicModuleParent: 44 | @classmethod 45 | def register(cls) -> DynamicModule: 46 | return DynamicModule( 47 | parent_module=cls, 48 | providers=list(providers or []), 49 | exports=list(exports or []), 50 | imports=list(imports or []), 51 | extensions=list(extensions or []), 52 | ) 53 | 54 | if name: # pragma: no cover 55 | DynamicModuleParent.__name__ = name 56 | 57 | return DynamicModuleParent.register() 58 | -------------------------------------------------------------------------------- /tests/modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/tests/modules/__init__.py -------------------------------------------------------------------------------- /tests/modules/test_dynamic_modules.py: -------------------------------------------------------------------------------- 1 | """Tests for dynamic module functionality.""" 2 | 3 | from waku import WakuFactory 4 | from waku.di import scoped 5 | 6 | from tests.data import A, AddDepOnConfigure 7 | from tests.module_utils import create_basic_module, create_dynamic_module 8 | 9 | 10 | def test_dynamic_module_configuration() -> None: 11 | """Dynamic modules should properly handle configuration extensions.""" 12 | SomeDynamicModule = create_dynamic_module( 13 | extensions=[AddDepOnConfigure(scoped(A))], 14 | name='SomeDynamicModule', 15 | ) 16 | AppModule = create_basic_module( 17 | imports=[SomeDynamicModule], 18 | name='AppModule', 19 | ) 20 | application = WakuFactory(AppModule).create() 21 | 22 | assert len(application.registry.get(SomeDynamicModule).providers) == 1 23 | 24 | 25 | def test_module_configuration_with_multiple_import_paths() -> None: 26 | """Module configuration should be applied only once when imported through multiple paths.""" 27 | SomeModule = create_dynamic_module( 28 | extensions=[AddDepOnConfigure(scoped(A))], 29 | name='SomeModule', 30 | ) 31 | ModuleA = create_basic_module( 32 | imports=[SomeModule], 33 | name='ModuleA', 34 | ) 35 | ModuleB = create_basic_module( 36 | imports=[SomeModule], 37 | name='ModuleB', 38 | ) 39 | AppModule = create_basic_module( 40 | imports=[ModuleA, ModuleB], 41 | name='AppModule', 42 | ) 43 | application = WakuFactory(AppModule).create() 44 | 45 | assert len(application.registry.get(SomeModule).providers) == 1 46 | -------------------------------------------------------------------------------- /tests/validation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/waku-py/waku/2533de0050a433e248c4df7e2c3f19e8aca4eee3/tests/validation/__init__.py --------------------------------------------------------------------------------