├── .dockerignore ├── .github ├── release.yml └── workflows │ ├── docs.yml │ ├── pypi.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── buf.gen.yaml ├── buf.work.yaml ├── docs ├── gen-api.py └── index.md ├── examples ├── __init__.py ├── auto_retry.py ├── fanout.py ├── getting_started.py ├── github_stats.py └── test_examples.py ├── mkdocs.yml ├── pyproject.toml ├── setup.py ├── src ├── buf │ ├── __init__.py │ └── validate │ │ ├── __init__.py │ │ ├── expression_pb2.py │ │ ├── expression_pb2.pyi │ │ ├── priv │ │ ├── __init__.py │ │ ├── private_pb2.py │ │ └── private_pb2.pyi │ │ ├── py.typed │ │ ├── validate_pb2.py │ │ └── validate_pb2.pyi └── dispatch │ ├── __init__.py │ ├── any.py │ ├── asyncio │ ├── __init__.py │ └── fastapi.py │ ├── config.py │ ├── coroutine.py │ ├── error.py │ ├── experimental │ ├── __init__.py │ ├── durable │ │ ├── README.md │ │ ├── __init__.py │ │ ├── frame.c │ │ ├── frame.pyi │ │ ├── frame308.h │ │ ├── frame309.h │ │ ├── frame310.h │ │ ├── frame311.h │ │ ├── frame312.h │ │ ├── frame313.h │ │ ├── function.py │ │ ├── py.typed │ │ └── registry.py │ └── lambda_handler.py │ ├── fastapi.py │ ├── flask.py │ ├── function.py │ ├── http.py │ ├── id.py │ ├── integrations │ ├── __init__.py │ ├── http.py │ ├── httpx.py │ ├── openai.py │ ├── py.typed │ ├── requests.py │ └── slack.py │ ├── proto.py │ ├── py.typed │ ├── scheduler.py │ ├── sdk │ ├── __init__.py │ ├── python │ │ ├── __init__.py │ │ └── v1 │ │ │ ├── __init__.py │ │ │ ├── pickled_pb2.py │ │ │ └── pickled_pb2.pyi │ └── v1 │ │ ├── __init__.py │ │ ├── call_pb2.py │ │ ├── call_pb2.pyi │ │ ├── dispatch_pb2.py │ │ ├── dispatch_pb2.pyi │ │ ├── error_pb2.py │ │ ├── error_pb2.pyi │ │ ├── exit_pb2.py │ │ ├── exit_pb2.pyi │ │ ├── function_pb2.py │ │ ├── function_pb2.pyi │ │ ├── poll_pb2.py │ │ ├── poll_pb2.pyi │ │ ├── py.typed │ │ ├── status_pb2.py │ │ └── status_pb2.pyi │ ├── signature │ ├── __init__.py │ ├── digest.py │ ├── key.py │ └── request.py │ ├── status.py │ └── test.py └── tests ├── __init__.py ├── dispatch ├── __init__.py ├── experimental │ ├── __init__.py │ └── durable │ │ ├── __init__.py │ │ ├── test_coroutine.py │ │ ├── test_frame.py │ │ └── test_generator.py ├── signature │ ├── __init__.py │ ├── test_digest.py │ ├── test_key.py │ └── test_signature.py ├── test_any.py ├── test_config.py ├── test_error.py ├── test_function.py ├── test_scheduler.py └── test_status.py ├── test_client.py ├── test_fastapi.py ├── test_flask.py └── test_http.py /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | __pycache__ 3 | *.md 4 | *.yaml 5 | *.yml 6 | dist/* 7 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: Breaking changes 4 | labels: 5 | - breaking change 6 | - title: New features 7 | labels: 8 | - enhancement 9 | - title: Bug fixes 10 | labels: 11 | - bug 12 | - title: Documentation 13 | labels: 14 | - documentation 15 | - title: Other changes 16 | labels: 17 | - "*" 18 | exclude: 19 | labels: 20 | - internal 21 | - title: Internal 22 | labels: 23 | - internal 24 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - mkdocs 8 | tags: 9 | - '*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-python@v5 21 | with: 22 | cache: pip 23 | python-version: '3.11' 24 | - run: make docs-deps 25 | 26 | - name: Configure the git user 27 | run: | 28 | git config user.name "Stealth Rocket" 29 | git config user.email "bot@stealthrocket.tech" 30 | 31 | - name: Build main branch 32 | if: ${{ github.ref_name == 'main' }} 33 | run: | 34 | mike deploy --push main 35 | - name: Build tagged version 36 | if: ${{ github.ref_name != 'main' }} 37 | run: | 38 | mike deploy --push --update-aliases ${{ github.ref_name }} latest 39 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.event.number || github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | archive: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v5 20 | with: 21 | cache: pip 22 | python-version: '3.11' 23 | - run: python -m pip install build 24 | - run: python -m build 25 | - uses: actions/upload-artifact@v4 26 | with: 27 | name: archive 28 | path: dist/*.tar.gz 29 | 30 | build: 31 | name: build ${{ matrix.build }} wheels on ${{ matrix.os }} 32 | runs-on: ${{ matrix.os }} 33 | permissions: 34 | contents: read 35 | strategy: 36 | matrix: 37 | build: 38 | - cp311 39 | - cp312 40 | os: 41 | - windows-latest 42 | - ubuntu-latest 43 | - macos-13 44 | - macos-14 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: pypa/cibuildwheel@v2.17.0 48 | env: 49 | CIBW_BUILD: ${{ matrix.build }}-* 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: cibw-wheels-${{ matrix.build }}-${{ matrix.os }} 53 | path: wheelhouse/*.whl 54 | 55 | build-linux-qemu: 56 | name: build ${{ matrix.build }} wheels on qemu for linux/${{ matrix.arch }} 57 | runs-on: ubuntu-latest 58 | permissions: 59 | contents: read 60 | strategy: 61 | matrix: 62 | arch: 63 | - aarch64 64 | - ppc64le 65 | - s390x 66 | build: 67 | - cp311 68 | - cp312 69 | steps: 70 | - uses: actions/checkout@v4 71 | - uses: docker/setup-qemu-action@v3 72 | with: 73 | platforms: all 74 | - uses: pypa/cibuildwheel@v2.17.0 75 | env: 76 | CIBW_BUILD: ${{ matrix.build }}-* 77 | CIBW_ARCHS: ${{ matrix.arch }} 78 | - uses: actions/upload-artifact@v4 79 | with: 80 | name: cibw-wheels-${{ matrix.build }}-linux-${{ matrix.arch }} 81 | path: wheelhouse/*.whl 82 | 83 | release: 84 | needs: 85 | - archive 86 | - build 87 | - build-linux-qemu 88 | runs-on: ubuntu-latest 89 | environment: 90 | name: pypi 91 | url: https://pypi.org/p/dispatch-py 92 | permissions: 93 | contents: read 94 | id-token: write 95 | steps: 96 | - uses: actions/checkout@v4 97 | - uses: actions/download-artifact@v4 98 | with: 99 | path: dist 100 | pattern: '*' 101 | merge-multiple: true 102 | - uses: pypa/gh-action-pypi-publish@release/v1 103 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: ncipollo/release-action@v1 16 | with: 17 | generateReleaseNotes: "true" 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.event.number || github.ref }} 8 | cancel-in-progress: true 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python: ["3.8", "3.9", "3.10", "3.11", "3.12"] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | cache: pip 26 | - run: make dev lambda 27 | - run: make test 28 | 29 | examples: 30 | runs-on: ubuntu-latest 31 | strategy: 32 | matrix: 33 | python: ["3.12"] 34 | steps: 35 | - uses: actions/checkout@v4 36 | - name: Set up Python ${{ matrix.python }} 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: ${{ matrix.python }} 40 | cache: pip 41 | - run: make dev 42 | - run: make exampletest 43 | 44 | format: 45 | runs-on: ubuntu-latest 46 | strategy: 47 | matrix: 48 | python: ["3.12"] 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Set up Python ${{ matrix.python }} 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ matrix.python }} 55 | cache: pip 56 | - run: make dev lambda 57 | - run: make fmt-check 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.so 3 | *.egg-info 4 | __pycache__ 5 | .proto 6 | .coverage 7 | .coverage-html 8 | dist/ 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Setup 4 | 5 | ``` 6 | make dev 7 | ``` 8 | 9 | ## Test 10 | 11 | ``` 12 | make test 13 | ``` 14 | 15 | ## Coverage 16 | 17 | ``` 18 | make coverage 19 | ``` 20 | 21 | In addition to displaying the summary in the terminal, this command generates an 22 | HTML report with line-by-line coverage. `open .coverage-html/index.html` and 23 | click around. You can refresh your browser after each `make coverage` run. 24 | 25 | ## Style 26 | 27 | Formatting is done with `black`. Run `make fmt`. 28 | 29 | Docstrings follow the [Google style][docstrings]. All public entities should 30 | have a docstring attached. 31 | 32 | [docstrings]: https://google.github.io/styleguide/pyguide.html#s3.8-comments-and-docstrings 33 | 34 | ## Documentation 35 | 36 | API reference documentation is automatically built on merges to `main` and new 37 | tagged version. To view the generated documentation locally: 38 | 39 | ``` 40 | make local-docs 41 | ``` 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12 2 | WORKDIR /usr/src/dispatch-py 3 | 4 | COPY pyproject.toml . 5 | RUN python -m pip install -e .[dev] 6 | 7 | COPY . . 8 | RUN python -m pip install -e .[dev] 9 | 10 | ENTRYPOINT ["python"] 11 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src 2 | graft test 3 | graft example 4 | 5 | include pyproject.toml 6 | include setup.py 7 | include README.md 8 | include CONTRIBUTING.md 9 | include LICENSE 10 | include Makefile 11 | include mkdocs.yml 12 | 13 | global-exclude *.so 14 | global-exclude *.py[cod] 15 | global-exclude __pycache__ 16 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: install test typecheck unittest dev fmt fmt-check generate clean update-proto coverage build check push push-test 2 | 3 | PYTHON := python 4 | 5 | ifdef PROTO_VERSION 6 | PROTO_TARGET := buf.build/stealthrocket/dispatch-proto:$(PROTO_VERSION) 7 | else 8 | PROTO_TARGET := buf.build/stealthrocket/dispatch-proto 9 | endif 10 | 11 | 12 | all: test 13 | 14 | install: 15 | $(PYTHON) -m pip install -e . 16 | 17 | dev: 18 | $(PYTHON) -m pip install -e .[dev] 19 | 20 | lambda: 21 | $(PYTHON) -m pip install -e .[lambda] 22 | 23 | fmt: 24 | $(PYTHON) -m isort . 25 | $(PYTHON) -m black . 26 | 27 | fmt-check: 28 | @$(PYTHON) -m isort . --check --diff; isort_status=$$?; \ 29 | $(PYTHON) -m black . --check --diff; black_status=$$?; \ 30 | exit $$((isort_status + black_status)) 31 | 32 | typecheck: 33 | $(PYTHON) -m mypy --check-untyped-defs src tests examples 34 | 35 | unittest: 36 | $(PYTHON) -m pytest tests 37 | 38 | exampletest: 39 | $(PYTHON) -m pytest examples 40 | 41 | coverage: typecheck 42 | coverage run -m unittest discover 43 | coverage html -d .coverage-html 44 | coverage report 45 | 46 | test: typecheck unittest 47 | 48 | .proto: 49 | mkdir -p $@ 50 | 51 | .proto/dispatch-proto: .proto 52 | buf export $(PROTO_TARGET) --output=.proto/dispatch-proto 53 | 54 | update-proto: 55 | $(MAKE) clean 56 | find . -type f -name '*_pb2*.py*' -exec rm {} \; 57 | $(MAKE) generate 58 | 59 | generate: .proto/dispatch-proto 60 | buf generate --template buf.gen.yaml 61 | cd src && find . -type d | while IFS= read -r dir; do touch $$dir/__init__.py; done 62 | rm src/__init__.py 63 | $(MAKE) fmt 64 | 65 | clean: 66 | $(RM) -r dist .proto .coverage .coverage-html 67 | find . -type f -name '*.pyc' | xargs $(RM) -r 68 | find . -type d -name '__pycache__' | xargs $(RM) -r 69 | 70 | build: 71 | $(PYTHON) -m build 72 | 73 | check: 74 | twine check dist/* 75 | 76 | push: 77 | twine upload dist/* 78 | 79 | push-test: 80 | twine upload -r testpypi dist/* 81 | 82 | 83 | docs-deps: dev 84 | $(PYTHON) -m pip install .[docs] 85 | 86 | local-docs: docs-deps 87 | mkdocs serve 88 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | managed: 3 | enabled: true 4 | 5 | plugins: 6 | - plugin: buf.build/protocolbuffers/python:v25.2 7 | out: src/ 8 | - plugin: buf.build/protocolbuffers/pyi:v25.2 9 | out: src/ 10 | - plugin: buf.build/grpc/python:v1.60.0 11 | out: src/ 12 | -------------------------------------------------------------------------------- /buf.work.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | directories: 3 | - .proto/dispatch-proto 4 | -------------------------------------------------------------------------------- /docs/gen-api.py: -------------------------------------------------------------------------------- 1 | # Adapted from https://github.com/mkdocstrings/griffe/blob/2359a02aef3cdc1776608a9e507c0096285e1d75/scripts/gen_ref_nav.py (ISC License) 2 | 3 | import fnmatch 4 | from pathlib import Path 5 | 6 | import mkdocs_gen_files 7 | 8 | nav = mkdocs_gen_files.Nav() 9 | mod_symbol = '' 10 | 11 | exclude = {"buf/*", "*/proto/*", "*_pb2*", "dispatch/sdk/*"} 12 | src = Path(__file__).parent.parent / "src" 13 | 14 | for path in sorted(src.rglob("*.py")): 15 | if any(fnmatch.fnmatch(str(path.relative_to(src)), pat) for pat in exclude): 16 | continue 17 | module_path = path.relative_to(src).with_suffix("") 18 | doc_path = path.relative_to(src).with_suffix(".md") 19 | full_doc_path = Path("reference", doc_path) 20 | 21 | parts = tuple(module_path.parts) 22 | 23 | if parts[-1] == "__init__": 24 | parts = parts[:-1] 25 | doc_path = doc_path.with_name("index.md") 26 | full_doc_path = full_doc_path.with_name("index.md") 27 | elif parts[-1].startswith("_"): 28 | continue 29 | 30 | nav_parts = [f"{mod_symbol} {part}" for part in parts] 31 | nav[tuple(nav_parts)] = doc_path.as_posix() 32 | 33 | with mkdocs_gen_files.open(full_doc_path, "w") as fd: 34 | ident = ".".join(parts) 35 | fd.write(f"::: {ident}") 36 | 37 | mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path) 38 | 39 | with mkdocs_gen_files.open("reference/SUMMARY.md", "w") as nav_file: 40 | nav_file.writelines(nav.build_literate_nav()) 41 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Dispatch Python SDK 2 | 3 | This is the API reference for the Python SDK of Dispatch. 4 | 5 | - Tutorials and guides: [docs.dispatch.run][docs]. 6 | - Source: [dispatchrun/dispatch-py][github]. 7 | 8 | 9 | [docs]: https://docs.dispatch.run 10 | [github]: https://github.com/dispatchrun/dispatch-py 11 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/examples/__init__.py -------------------------------------------------------------------------------- /examples/auto_retry.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import requests 4 | 5 | import dispatch 6 | import dispatch.integrations.requests 7 | 8 | rng = random.Random(2) 9 | 10 | 11 | def third_party_api_call(x): 12 | # Simulate a third-party API call that fails. 13 | print(f"Simulating third-party API call with {x}") 14 | if x < 3: 15 | print("RAISE EXCEPTION") 16 | raise requests.RequestException("Simulated failure") 17 | else: 18 | return "SUCCESS" 19 | 20 | 21 | # Use the `dispatch.function` decorator to declare a stateful function. 22 | @dispatch.function 23 | def auto_retry(): 24 | x = rng.randint(0, 5) 25 | return third_party_api_call(x) 26 | 27 | 28 | if __name__ == "__main__": 29 | print(dispatch.run(auto_retry())) 30 | -------------------------------------------------------------------------------- /examples/fanout.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | import dispatch 4 | 5 | 6 | @dispatch.function 7 | def get_repo(repo_owner: str, repo_name: str): 8 | url = f"https://api.github.com/repos/{repo_owner}/{repo_name}" 9 | api_response = httpx.get(url) 10 | api_response.raise_for_status() 11 | repo_info = api_response.json() 12 | return repo_info 13 | 14 | 15 | @dispatch.function 16 | def get_stargazers(repo_info): 17 | url = repo_info["stargazers_url"] 18 | response = httpx.get(url) 19 | response.raise_for_status() 20 | stargazers = response.json() 21 | return stargazers 22 | 23 | 24 | @dispatch.function 25 | async def reduce_stargazers(repos): 26 | result = await dispatch.gather(*[get_stargazers(repo) for repo in repos]) 27 | reduced_stars = set() 28 | for repo in result: 29 | for stars in repo: 30 | reduced_stars.add(stars["login"]) 31 | return reduced_stars 32 | 33 | 34 | @dispatch.function 35 | async def fanout(): 36 | # Using gather, we fan-out the following requests: 37 | repos = await dispatch.gather( 38 | get_repo("dispatchrun", "coroutine"), 39 | get_repo("dispatchrun", "dispatch-py"), 40 | get_repo("dispatchrun", "wzprof"), 41 | ) 42 | return await reduce_stargazers(repos) 43 | 44 | 45 | if __name__ == "__main__": 46 | print(dispatch.run(fanout())) 47 | -------------------------------------------------------------------------------- /examples/getting_started.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | import dispatch 4 | 5 | 6 | @dispatch.function 7 | def publish(url, payload): 8 | r = requests.post(url, data=payload) 9 | r.raise_for_status() 10 | return r.text 11 | 12 | 13 | @dispatch.function 14 | async def getting_started(): 15 | return await publish("https://httpstat.us/200", {"hello": "world"}) 16 | 17 | 18 | if __name__ == "__main__": 19 | print(dispatch.run(getting_started())) 20 | -------------------------------------------------------------------------------- /examples/github_stats.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | import dispatch 4 | from dispatch.error import ThrottleError 5 | 6 | 7 | def get_gh_api(url): 8 | print(f"GET {url}") 9 | response = httpx.get(url) 10 | X_RateLimit_Remaining = response.headers.get("X-RateLimit-Remaining") 11 | if response.status_code == 403 and X_RateLimit_Remaining == "0": 12 | raise ThrottleError("Rate limit exceeded") 13 | response.raise_for_status() 14 | return response.json() 15 | 16 | 17 | @dispatch.function 18 | def get_repo_info(repo_owner, repo_name): 19 | url = f"https://api.github.com/repos/{repo_owner}/{repo_name}" 20 | repo_info = get_gh_api(url) 21 | return repo_info 22 | 23 | 24 | @dispatch.function 25 | def get_contributors(repo_info): 26 | url = repo_info["contributors_url"] 27 | contributors = get_gh_api(url) 28 | return contributors 29 | 30 | 31 | @dispatch.function 32 | async def github_stats(): 33 | repo_info = await get_repo_info("dispatchrun", "coroutine") 34 | print( 35 | f"""Repository: {repo_info['full_name']} 36 | Stars: {repo_info['stargazers_count']} 37 | Watchers: {repo_info['watchers_count']} 38 | Forks: {repo_info['forks_count']}""" 39 | ) 40 | return await get_contributors(repo_info) 41 | 42 | 43 | if __name__ == "__main__": 44 | contributors = dispatch.run(github_stats()) 45 | print(f"Contributors: {len(contributors)}") 46 | -------------------------------------------------------------------------------- /examples/test_examples.py: -------------------------------------------------------------------------------- 1 | import dispatch.test 2 | 3 | from .auto_retry import auto_retry 4 | from .fanout import fanout 5 | from .getting_started import getting_started 6 | from .github_stats import github_stats 7 | 8 | 9 | @dispatch.test.function 10 | async def test_auto_retry(): 11 | assert await auto_retry() == "SUCCESS" 12 | 13 | 14 | @dispatch.test.function 15 | async def test_fanout(): 16 | contributors = await fanout() 17 | assert len(contributors) >= 15 18 | assert "achille-roussel" in contributors 19 | 20 | 21 | @dispatch.test.function 22 | async def test_getting_started(): 23 | assert await getting_started() == "200 OK" 24 | 25 | 26 | @dispatch.test.function 27 | async def test_github_stats(): 28 | contributors = await github_stats() 29 | assert len(contributors) >= 6 30 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Dispatch SDK 2 | 3 | nav: 4 | - API Reference: 5 | - Dispatch: reference/ 6 | 7 | theme: 8 | name: "material" 9 | features: 10 | - navigation.indexes 11 | palette: 12 | - media: "(prefers-color-scheme)" 13 | toggle: 14 | icon: material/brightness-auto 15 | name: Switch to light mode 16 | - media: "(prefers-color-scheme: light)" 17 | scheme: default 18 | toggle: 19 | icon: material/brightness-7 20 | name: Switch to dark mode 21 | - media: "(prefers-color-scheme: dark)" 22 | scheme: slate 23 | toggle: 24 | icon: material/brightness-4 25 | name: Switch to system preference 26 | 27 | plugins: 28 | - search 29 | - gen-files: 30 | scripts: 31 | - docs/gen-api.py 32 | - literate-nav: 33 | nav_file: SUMMARY.md 34 | - mkdocstrings: 35 | handlers: 36 | python: 37 | options: 38 | members_order: source 39 | merge_init_into_class: true 40 | show_if_no_docstring: false 41 | show_source: no 42 | docstring_options: 43 | ignore_init_summary: true 44 | heading_level: 1 45 | inherited_members: true 46 | merge_init_into_class: true 47 | separate_signature: true 48 | show_root_heading: true 49 | show_root_full_path: false 50 | show_source: false 51 | show_signature_annotations: true 52 | show_symbol_type_heading: true 53 | show_symbol_type_toc: true 54 | signature_crossrefs: true 55 | summary: true 56 | - mike 57 | 58 | extra: 59 | version: 60 | provider: mike 61 | 62 | markdown_extensions: 63 | - attr_list 64 | - admonition 65 | - toc: 66 | permalink: "¤" 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.0", "wheel", "setuptools-git-versioning<2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "dispatch-py" 7 | description = "Develop reliable distributed systems with Dispatch." 8 | readme = "README.md" 9 | dynamic = ["version"] 10 | requires-python = ">= 3.8" 11 | dependencies = [ 12 | "aiohttp >= 3.9.4", 13 | "protobuf >= 4.24.0", 14 | "types-protobuf >= 4.24.0.20240129", 15 | "http-message-signatures >= 0.5.0", 16 | "tblib >= 3.0.0", 17 | "typing_extensions >= 4.10" 18 | ] 19 | 20 | [project.optional-dependencies] 21 | fastapi = ["fastapi"] 22 | flask = ["flask"] 23 | httpx = ["httpx"] 24 | lambda = ["awslambdaric"] 25 | 26 | dev = [ 27 | "httpx >= 0.27.0", 28 | "black >= 24.1.0", 29 | "isort >= 5.13.2", 30 | "mypy >= 1.10.0", 31 | "pytest >= 8.0.0", 32 | "pytest-asyncio >= 0.23.7", 33 | "fastapi >= 0.109.0", 34 | "coverage >= 7.4.1", 35 | "requests >= 2.31.0", 36 | "types-requests >= 2.31.0.20240125", 37 | "uvicorn >= 0.28.0", 38 | "types-Flask >= 1.1.6", 39 | "flask >= 3", 40 | "awslambdaric-stubs" 41 | ] 42 | 43 | docs = [ 44 | "mkdocs==1.5.3", 45 | "mkdocstrings[python]==0.24.0", 46 | "mkdocs-material==9.5.9", 47 | "mkdocs-gen-files==0.5.0", 48 | "mkdocs-literate-nav==0.6.1", 49 | "mike==2.0.0", 50 | ] 51 | 52 | [tool.setuptools-git-versioning] 53 | enabled = true 54 | dev_template = "{tag}" 55 | dirty_template = "{tag}" 56 | 57 | [tool.isort] 58 | profile = "black" 59 | src_paths = ["src"] 60 | 61 | [tool.coverage.run] 62 | omit = ["*_pb2.py", "tests/*", "examples/*", "src/buf/*"] 63 | 64 | [tool.mypy] 65 | exclude = [ 66 | '^src/buf', 67 | '^tests/examples', 68 | ] 69 | 70 | [tool.pytest.ini_options] 71 | testpaths = ['tests'] 72 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import Extension, setup 2 | 3 | setup( 4 | ext_modules=[ 5 | Extension( 6 | name="dispatch.experimental.durable.frame", 7 | sources=["src/dispatch/experimental/durable/frame.c"], 8 | include_dirs=["src/dispatch/experimental/durable"], 9 | ), 10 | ] 11 | ) 12 | -------------------------------------------------------------------------------- /src/buf/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/buf/__init__.py -------------------------------------------------------------------------------- /src/buf/validate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/buf/validate/__init__.py -------------------------------------------------------------------------------- /src/buf/validate/expression_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: buf/validate/expression.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 17 | b'\n\x1d\x62uf/validate/expression.proto\x12\x0c\x62uf.validate"V\n\nConstraint\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n\x07message\x18\x02 \x01(\tR\x07message\x12\x1e\n\nexpression\x18\x03 \x01(\tR\nexpression"E\n\nViolations\x12\x37\n\nviolations\x18\x01 \x03(\x0b\x32\x17.buf.validate.ViolationR\nviolations"\x82\x01\n\tViolation\x12\x1d\n\nfield_path\x18\x01 \x01(\tR\tfieldPath\x12#\n\rconstraint_id\x18\x02 \x01(\tR\x0c\x63onstraintId\x12\x18\n\x07message\x18\x03 \x01(\tR\x07message\x12\x17\n\x07\x66or_key\x18\x04 \x01(\x08R\x06\x66orKeyB\xbd\x01\n\x10\x63om.buf.validateB\x0f\x45xpressionProtoP\x01ZGbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate\xa2\x02\x03\x42VX\xaa\x02\x0c\x42uf.Validate\xca\x02\x0c\x42uf\\Validate\xe2\x02\x18\x42uf\\Validate\\GPBMetadata\xea\x02\rBuf::Validateb\x06proto3' 18 | ) 19 | 20 | _globals = globals() 21 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 22 | _builder.BuildTopDescriptorsAndMessages( 23 | DESCRIPTOR, "buf.validate.expression_pb2", _globals 24 | ) 25 | if _descriptor._USE_C_DESCRIPTORS == False: 26 | _globals["DESCRIPTOR"]._options = None 27 | _globals["DESCRIPTOR"]._serialized_options = ( 28 | b"\n\020com.buf.validateB\017ExpressionProtoP\001ZGbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate\242\002\003BVX\252\002\014Buf.Validate\312\002\014Buf\\Validate\342\002\030Buf\\Validate\\GPBMetadata\352\002\rBuf::Validate" 29 | ) 30 | _globals["_CONSTRAINT"]._serialized_start = 47 31 | _globals["_CONSTRAINT"]._serialized_end = 133 32 | _globals["_VIOLATIONS"]._serialized_start = 135 33 | _globals["_VIOLATIONS"]._serialized_end = 204 34 | _globals["_VIOLATION"]._serialized_start = 207 35 | _globals["_VIOLATION"]._serialized_end = 337 36 | # @@protoc_insertion_point(module_scope) 37 | -------------------------------------------------------------------------------- /src/buf/validate/expression_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Iterable as _Iterable 3 | from typing import Mapping as _Mapping 4 | from typing import Optional as _Optional 5 | from typing import Union as _Union 6 | 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import message as _message 9 | from google.protobuf.internal import containers as _containers 10 | 11 | DESCRIPTOR: _descriptor.FileDescriptor 12 | 13 | class Constraint(_message.Message): 14 | __slots__ = ("id", "message", "expression") 15 | ID_FIELD_NUMBER: _ClassVar[int] 16 | MESSAGE_FIELD_NUMBER: _ClassVar[int] 17 | EXPRESSION_FIELD_NUMBER: _ClassVar[int] 18 | id: str 19 | message: str 20 | expression: str 21 | def __init__( 22 | self, 23 | id: _Optional[str] = ..., 24 | message: _Optional[str] = ..., 25 | expression: _Optional[str] = ..., 26 | ) -> None: ... 27 | 28 | class Violations(_message.Message): 29 | __slots__ = ("violations",) 30 | VIOLATIONS_FIELD_NUMBER: _ClassVar[int] 31 | violations: _containers.RepeatedCompositeFieldContainer[Violation] 32 | def __init__( 33 | self, violations: _Optional[_Iterable[_Union[Violation, _Mapping]]] = ... 34 | ) -> None: ... 35 | 36 | class Violation(_message.Message): 37 | __slots__ = ("field_path", "constraint_id", "message", "for_key") 38 | FIELD_PATH_FIELD_NUMBER: _ClassVar[int] 39 | CONSTRAINT_ID_FIELD_NUMBER: _ClassVar[int] 40 | MESSAGE_FIELD_NUMBER: _ClassVar[int] 41 | FOR_KEY_FIELD_NUMBER: _ClassVar[int] 42 | field_path: str 43 | constraint_id: str 44 | message: str 45 | for_key: bool 46 | def __init__( 47 | self, 48 | field_path: _Optional[str] = ..., 49 | constraint_id: _Optional[str] = ..., 50 | message: _Optional[str] = ..., 51 | for_key: bool = ..., 52 | ) -> None: ... 53 | -------------------------------------------------------------------------------- /src/buf/validate/priv/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/buf/validate/priv/__init__.py -------------------------------------------------------------------------------- /src/buf/validate/priv/private_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: buf/validate/priv/private.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | from google.protobuf import descriptor_pb2 as google_dot_protobuf_dot_descriptor__pb2 17 | 18 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 19 | b'\n\x1f\x62uf/validate/priv/private.proto\x12\x11\x62uf.validate.priv\x1a google/protobuf/descriptor.proto"C\n\x10\x46ieldConstraints\x12/\n\x03\x63\x65l\x18\x01 \x03(\x0b\x32\x1d.buf.validate.priv.ConstraintR\x03\x63\x65l"V\n\nConstraint\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n\x07message\x18\x02 \x01(\tR\x07message\x12\x1e\n\nexpression\x18\x03 \x01(\tR\nexpression:\\\n\x05\x66ield\x12\x1d.google.protobuf.FieldOptions\x18\x88\t \x01(\x0b\x32#.buf.validate.priv.FieldConstraintsR\x05\x66ield\x88\x01\x01\x42\xd9\x01\n\x15\x63om.buf.validate.privB\x0cPrivateProtoP\x01ZLbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate/priv\xa2\x02\x03\x42VP\xaa\x02\x11\x42uf.Validate.Priv\xca\x02\x11\x42uf\\Validate\\Priv\xe2\x02\x1d\x42uf\\Validate\\Priv\\GPBMetadata\xea\x02\x13\x42uf::Validate::Privb\x06proto3' 20 | ) 21 | 22 | _globals = globals() 23 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 24 | _builder.BuildTopDescriptorsAndMessages( 25 | DESCRIPTOR, "buf.validate.priv.private_pb2", _globals 26 | ) 27 | if _descriptor._USE_C_DESCRIPTORS == False: 28 | _globals["DESCRIPTOR"]._options = None 29 | _globals["DESCRIPTOR"]._serialized_options = ( 30 | b"\n\025com.buf.validate.privB\014PrivateProtoP\001ZLbuf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate/priv\242\002\003BVP\252\002\021Buf.Validate.Priv\312\002\021Buf\\Validate\\Priv\342\002\035Buf\\Validate\\Priv\\GPBMetadata\352\002\023Buf::Validate::Priv" 31 | ) 32 | _globals["_FIELDCONSTRAINTS"]._serialized_start = 88 33 | _globals["_FIELDCONSTRAINTS"]._serialized_end = 155 34 | _globals["_CONSTRAINT"]._serialized_start = 157 35 | _globals["_CONSTRAINT"]._serialized_end = 243 36 | # @@protoc_insertion_point(module_scope) 37 | -------------------------------------------------------------------------------- /src/buf/validate/priv/private_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Iterable as _Iterable 3 | from typing import Mapping as _Mapping 4 | from typing import Optional as _Optional 5 | from typing import Union as _Union 6 | 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import descriptor_pb2 as _descriptor_pb2 9 | from google.protobuf import message as _message 10 | from google.protobuf.internal import containers as _containers 11 | 12 | DESCRIPTOR: _descriptor.FileDescriptor 13 | FIELD_FIELD_NUMBER: _ClassVar[int] 14 | field: _descriptor.FieldDescriptor 15 | 16 | class FieldConstraints(_message.Message): 17 | __slots__ = ("cel",) 18 | CEL_FIELD_NUMBER: _ClassVar[int] 19 | cel: _containers.RepeatedCompositeFieldContainer[Constraint] 20 | def __init__( 21 | self, cel: _Optional[_Iterable[_Union[Constraint, _Mapping]]] = ... 22 | ) -> None: ... 23 | 24 | class Constraint(_message.Message): 25 | __slots__ = ("id", "message", "expression") 26 | ID_FIELD_NUMBER: _ClassVar[int] 27 | MESSAGE_FIELD_NUMBER: _ClassVar[int] 28 | EXPRESSION_FIELD_NUMBER: _ClassVar[int] 29 | id: str 30 | message: str 31 | expression: str 32 | def __init__( 33 | self, 34 | id: _Optional[str] = ..., 35 | message: _Optional[str] = ..., 36 | expression: _Optional[str] = ..., 37 | ) -> None: ... 38 | -------------------------------------------------------------------------------- /src/buf/validate/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/buf/validate/py.typed -------------------------------------------------------------------------------- /src/dispatch/__init__.py: -------------------------------------------------------------------------------- 1 | """The Dispatch SDK for Python.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | import os 7 | from http.server import ThreadingHTTPServer 8 | from typing import Any, Awaitable, Callable, Coroutine, Optional, TypeVar, overload 9 | from urllib.parse import urlsplit 10 | 11 | from typing_extensions import ParamSpec, TypeAlias 12 | 13 | import dispatch.integrations 14 | from dispatch.coroutine import all, any, call, gather, race 15 | from dispatch.function import AsyncFunction as Function 16 | from dispatch.function import ( 17 | Batch, 18 | Client, 19 | ClientError, 20 | Registry, 21 | Reset, 22 | default_registry, 23 | ) 24 | from dispatch.http import Dispatch, Server 25 | from dispatch.id import DispatchID 26 | from dispatch.proto import Call, Error, Input, Output 27 | from dispatch.status import Status 28 | 29 | __all__ = [ 30 | "Call", 31 | "Client", 32 | "ClientError", 33 | "DispatchID", 34 | "Error", 35 | "Input", 36 | "Output", 37 | "Registry", 38 | "Reset", 39 | "Status", 40 | "all", 41 | "any", 42 | "call", 43 | "function", 44 | "gather", 45 | "race", 46 | "run", 47 | "serve", 48 | ] 49 | 50 | 51 | P = ParamSpec("P") 52 | T = TypeVar("T") 53 | 54 | 55 | @overload 56 | def function(func: Callable[P, Coroutine[Any, Any, T]]) -> Function[P, T]: ... 57 | 58 | 59 | @overload 60 | def function(func: Callable[P, T]) -> Function[P, T]: ... 61 | 62 | 63 | def function(func): 64 | return default_registry().function(func) 65 | 66 | 67 | async def main(coro: Coroutine[Any, Any, T], addr: Optional[str] = None) -> T: 68 | """Entrypoint of dispatch applications. This function creates a new 69 | Dispatch server and runs the provided coroutine in the server's event loop. 70 | 71 | Programs typically don't use this function directly, unless they manage 72 | their own event loop. Most of the time, the `run` function is a more 73 | convenient way to run a dispatch application. 74 | 75 | Args: 76 | coro: The coroutine to run as the entrypoint, the function returns 77 | when the coroutine returns. 78 | 79 | addr: The address to bind the server to. If not provided, the server 80 | will bind to the address specified by the `DISPATCH_ENDPOINT_ADDR` 81 | 82 | Returns: 83 | The value returned by the coroutine. 84 | """ 85 | address = addr or str(os.environ.get("DISPATCH_ENDPOINT_ADDR")) or "localhost:8000" 86 | parsed_url = urlsplit("//" + address) 87 | 88 | host = parsed_url.hostname or "" 89 | port = parsed_url.port or 0 90 | 91 | reg = default_registry() 92 | app = Dispatch(reg) 93 | 94 | async with Server(host, port, app) as server: 95 | return await coro 96 | 97 | 98 | def run(coro: Coroutine[Any, Any, T], addr: Optional[str] = None) -> T: 99 | """Run the default Dispatch server. The default server uses a function 100 | registry where functions tagged by the `@dispatch.function` decorator are 101 | registered. 102 | 103 | This function is intended to be used with the `dispatch` CLI tool, which 104 | automatically configures environment variables to connect the local server 105 | to the Dispatch bridge API. 106 | 107 | Args: 108 | coro: The coroutine to run as the entrypoint, the function returns 109 | when the coroutine returns. 110 | 111 | addr: The address to bind the server to. If not provided, the server 112 | will bind to the address specified by the `DISPATCH_ENDPOINT_ADDR` 113 | environment variable. If the environment variable is not set, the 114 | server will bind to `localhost:8000`. 115 | 116 | Returns: 117 | The value returned by the coroutine. 118 | """ 119 | return asyncio.run(main(coro, addr)) 120 | 121 | 122 | def run_forever( 123 | coro: Optional[Coroutine[Any, Any, T]] = None, addr: Optional[str] = None 124 | ): 125 | """Run the default Dispatch server forever. 126 | 127 | Args: 128 | coro: A coroutine to optionally run as the entrypoint. 129 | 130 | addr: The address to bind the server to. If not provided, the server 131 | will bind to the address specified by the `DISPATCH_ENDPOINT_ADDR` 132 | environment variable. If the environment variable is not set, the 133 | server will bind to `localhost:8000`. 134 | """ 135 | wait = asyncio.Event().wait() 136 | coro = chain(coro, wait) if coro is not None else wait 137 | return run(coro=coro, addr=addr) 138 | 139 | 140 | async def chain(*awaitables: Awaitable[Any]): 141 | for a in awaitables: 142 | await a 143 | 144 | 145 | def batch() -> Batch: 146 | """Create a new batch object.""" 147 | return default_registry().batch() 148 | -------------------------------------------------------------------------------- /src/dispatch/any.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pickle 4 | from datetime import datetime, timedelta, timezone 5 | from typing import Any 6 | 7 | import google.protobuf.any_pb2 8 | import google.protobuf.duration_pb2 9 | import google.protobuf.empty_pb2 10 | import google.protobuf.message 11 | import google.protobuf.struct_pb2 12 | import google.protobuf.timestamp_pb2 13 | import google.protobuf.wrappers_pb2 14 | from google.protobuf import descriptor_pool, message_factory 15 | 16 | from dispatch.sdk.python.v1 import pickled_pb2 as pickled_pb 17 | 18 | INT64_MIN = -9223372036854775808 19 | INT64_MAX = 9223372036854775807 20 | 21 | 22 | def marshal_any(value: Any) -> google.protobuf.any_pb2.Any: 23 | if value is None: 24 | value = google.protobuf.empty_pb2.Empty() 25 | elif isinstance(value, bool): 26 | value = google.protobuf.wrappers_pb2.BoolValue(value=value) 27 | elif isinstance(value, int) and INT64_MIN <= value <= INT64_MAX: 28 | # To keep things simple, serialize all integers as int64 on the wire. 29 | # For larger integers, fall through and use pickle. 30 | value = google.protobuf.wrappers_pb2.Int64Value(value=value) 31 | elif isinstance(value, float): 32 | value = google.protobuf.wrappers_pb2.DoubleValue(value=value) 33 | elif isinstance(value, str): 34 | value = google.protobuf.wrappers_pb2.StringValue(value=value) 35 | elif isinstance(value, bytes): 36 | value = google.protobuf.wrappers_pb2.BytesValue(value=value) 37 | elif isinstance(value, datetime): 38 | # Note: datetime only supports microsecond granularity 39 | seconds = int(value.timestamp()) 40 | nanos = value.microsecond * 1000 41 | value = google.protobuf.timestamp_pb2.Timestamp(seconds=seconds, nanos=nanos) 42 | elif isinstance(value, timedelta): 43 | # Note: timedelta only supports microsecond granularity 44 | seconds = int(value.total_seconds()) 45 | nanos = value.microseconds * 1000 46 | value = google.protobuf.duration_pb2.Duration(seconds=seconds, nanos=nanos) 47 | 48 | if isinstance(value, list) or isinstance(value, dict): 49 | try: 50 | value = as_struct_value(value) 51 | except ValueError: 52 | pass # fallthrough 53 | 54 | if not isinstance(value, google.protobuf.message.Message): 55 | value = pickled_pb.Pickled(pickled_value=pickle.dumps(value)) 56 | 57 | any = google.protobuf.any_pb2.Any() 58 | if value.DESCRIPTOR.full_name.startswith("dispatch.sdk."): 59 | any.Pack(value, type_url_prefix="buf.build/stealthrocket/dispatch-proto/") 60 | else: 61 | any.Pack(value) 62 | 63 | return any 64 | 65 | 66 | def unmarshal_any(any: google.protobuf.any_pb2.Any) -> Any: 67 | pool = descriptor_pool.Default() 68 | msg_descriptor = pool.FindMessageTypeByName(any.TypeName()) 69 | proto = message_factory.GetMessageClass(msg_descriptor)() 70 | any.Unpack(proto) 71 | 72 | if isinstance(proto, pickled_pb.Pickled): 73 | return pickle.loads(proto.pickled_value) 74 | 75 | elif isinstance(proto, google.protobuf.empty_pb2.Empty): 76 | return None 77 | 78 | elif isinstance(proto, google.protobuf.wrappers_pb2.BoolValue): 79 | return proto.value 80 | 81 | elif isinstance(proto, google.protobuf.wrappers_pb2.Int32Value): 82 | return proto.value 83 | 84 | elif isinstance(proto, google.protobuf.wrappers_pb2.Int64Value): 85 | return proto.value 86 | 87 | elif isinstance(proto, google.protobuf.wrappers_pb2.UInt32Value): 88 | return proto.value 89 | 90 | elif isinstance(proto, google.protobuf.wrappers_pb2.UInt64Value): 91 | return proto.value 92 | 93 | elif isinstance(proto, google.protobuf.wrappers_pb2.FloatValue): 94 | return proto.value 95 | 96 | elif isinstance(proto, google.protobuf.wrappers_pb2.DoubleValue): 97 | return proto.value 98 | 99 | elif isinstance(proto, google.protobuf.wrappers_pb2.StringValue): 100 | return proto.value 101 | 102 | elif isinstance(proto, google.protobuf.wrappers_pb2.BytesValue): 103 | try: 104 | # Assume it's the legacy container for pickled values. 105 | return pickle.loads(proto.value) 106 | except Exception as e: 107 | # Otherwise, return the literal bytes. 108 | return proto.value 109 | 110 | elif isinstance(proto, google.protobuf.timestamp_pb2.Timestamp): 111 | return proto.ToDatetime(tzinfo=timezone.utc) 112 | 113 | elif isinstance(proto, google.protobuf.duration_pb2.Duration): 114 | return proto.ToTimedelta() 115 | 116 | elif isinstance(proto, google.protobuf.struct_pb2.Value): 117 | return from_struct_value(proto) 118 | 119 | return proto 120 | 121 | 122 | def as_struct_value(value: Any) -> google.protobuf.struct_pb2.Value: 123 | if value is None: 124 | null_value = google.protobuf.struct_pb2.NullValue.NULL_VALUE 125 | return google.protobuf.struct_pb2.Value(null_value=null_value) 126 | 127 | elif isinstance(value, bool): 128 | return google.protobuf.struct_pb2.Value(bool_value=value) 129 | 130 | elif isinstance(value, int) or isinstance(value, float): 131 | return google.protobuf.struct_pb2.Value(number_value=float(value)) 132 | 133 | elif isinstance(value, str): 134 | return google.protobuf.struct_pb2.Value(string_value=value) 135 | 136 | elif isinstance(value, list): 137 | list_value = google.protobuf.struct_pb2.ListValue( 138 | values=[as_struct_value(v) for v in value] 139 | ) 140 | return google.protobuf.struct_pb2.Value(list_value=list_value) 141 | 142 | elif isinstance(value, dict): 143 | for key in value.keys(): 144 | if not isinstance(key, str): 145 | raise ValueError("unsupported object key") 146 | 147 | struct_value = google.protobuf.struct_pb2.Struct( 148 | fields={k: as_struct_value(v) for k, v in value.items()} 149 | ) 150 | return google.protobuf.struct_pb2.Value(struct_value=struct_value) 151 | 152 | raise ValueError("unsupported value") 153 | 154 | 155 | def from_struct_value(value: google.protobuf.struct_pb2.Value) -> Any: 156 | if value.HasField("null_value"): 157 | return None 158 | elif value.HasField("bool_value"): 159 | return value.bool_value 160 | elif value.HasField("number_value"): 161 | return value.number_value 162 | elif value.HasField("string_value"): 163 | return value.string_value 164 | elif value.HasField("list_value"): 165 | 166 | return [from_struct_value(v) for v in value.list_value.values] 167 | elif value.HasField("struct_value"): 168 | return {k: from_struct_value(v) for k, v in value.struct_value.fields.items()} 169 | else: 170 | raise RuntimeError(f"invalid struct_pb2.Value: {value}") 171 | -------------------------------------------------------------------------------- /src/dispatch/asyncio/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import inspect 4 | import signal 5 | import threading 6 | 7 | 8 | class Runner: 9 | """Runner is a class similar to asyncio.Runner but that we use for backward 10 | compatibility with Python 3.10 and earlier. 11 | """ 12 | 13 | def __init__(self): 14 | self._loop = asyncio.new_event_loop() 15 | self._interrupt_count = 0 16 | 17 | def __enter__(self): 18 | return self 19 | 20 | def __exit__(self, *args, **kwargs): 21 | self.close() 22 | 23 | def close(self): 24 | try: 25 | loop = self._loop 26 | _cancel_all_tasks(loop) 27 | loop.run_until_complete(loop.shutdown_asyncgens()) 28 | if hasattr(loop, "shutdown_default_executor"): # Python 3.9+ 29 | loop.run_until_complete(loop.shutdown_default_executor()) 30 | finally: 31 | loop.close() 32 | 33 | def get_loop(self): 34 | return self._loop 35 | 36 | def run(self, coro): 37 | if not inspect.iscoroutine(coro): 38 | raise ValueError("a coroutine was expected, got {!r}".format(coro)) 39 | 40 | try: 41 | asyncio.get_running_loop() 42 | except RuntimeError: 43 | pass 44 | else: 45 | raise RuntimeError( 46 | "Runner.run() cannot be called from a running event loop" 47 | ) 48 | 49 | task = self._loop.create_task(coro) 50 | sigint_handler = None 51 | 52 | if ( 53 | threading.current_thread() is threading.main_thread() 54 | and signal.getsignal(signal.SIGINT) is signal.default_int_handler 55 | ): 56 | sigint_handler = functools.partial(self._on_sigint, main_task=task) 57 | try: 58 | signal.signal(signal.SIGINT, sigint_handler) 59 | except ValueError: 60 | # `signal.signal` may throw if `threading.main_thread` does 61 | # not support signals (e.g. embedded interpreter with signals 62 | # not registered - see gh-91880) 63 | sigint_handler = None 64 | 65 | self._interrupt_count = 0 66 | try: 67 | asyncio.set_event_loop(self._loop) 68 | return self._loop.run_until_complete(task) 69 | except asyncio.CancelledError: 70 | if self._interrupt_count > 0: 71 | uncancel = getattr(task, "uncancel", None) 72 | if uncancel is not None and uncancel() == 0: 73 | raise KeyboardInterrupt() 74 | raise # CancelledError 75 | finally: 76 | asyncio.set_event_loop(None) 77 | if ( 78 | sigint_handler is not None 79 | and signal.getsignal(signal.SIGINT) is sigint_handler 80 | ): 81 | signal.signal(signal.SIGINT, signal.default_int_handler) 82 | 83 | def _on_sigint(self, signum, frame, main_task): 84 | self._interrupt_count += 1 85 | if self._interrupt_count == 1 and not main_task.done(): 86 | main_task.cancel() 87 | # wakeup loop if it is blocked by select() with long timeout 88 | self._loop.call_soon_threadsafe(lambda: None) 89 | return 90 | raise KeyboardInterrupt() 91 | 92 | 93 | def _cancel_all_tasks(loop): 94 | to_cancel = asyncio.all_tasks(loop) 95 | if not to_cancel: 96 | return 97 | 98 | for task in to_cancel: 99 | task.cancel() 100 | 101 | loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True)) 102 | 103 | for task in to_cancel: 104 | if task.cancelled(): 105 | continue 106 | if task.exception() is not None: 107 | loop.call_exception_handler( 108 | { 109 | "message": "unhandled exception during asyncio.run() shutdown", 110 | "exception": task.exception(), 111 | "task": task, 112 | } 113 | ) 114 | -------------------------------------------------------------------------------- /src/dispatch/asyncio/fastapi.py: -------------------------------------------------------------------------------- 1 | """Integration of Dispatch functions with FastAPI for handlers using asyncio. 2 | 3 | Example: 4 | 5 | import fastapi 6 | from dispatch.asyncio.fastapi import Dispatch 7 | 8 | app = fastapi.FastAPI() 9 | dispatch = Dispatch(app) 10 | 11 | @dispatch.function 12 | def my_function(): 13 | return "Hello World!" 14 | 15 | @app.get("/") 16 | async def read_root(): 17 | await my_function.dispatch() 18 | """ 19 | 20 | import logging 21 | from typing import Optional, Union 22 | 23 | import fastapi 24 | import fastapi.responses 25 | 26 | from dispatch.function import Registry 27 | from dispatch.http import ( 28 | AsyncFunctionService, 29 | FunctionServiceError, 30 | validate_content_length, 31 | ) 32 | from dispatch.signature import Ed25519PublicKey, parse_verification_key 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class Dispatch(AsyncFunctionService): 38 | """A Dispatch instance, powered by FastAPI.""" 39 | 40 | def __init__( 41 | self, 42 | app: fastapi.FastAPI, 43 | registry: Optional[Registry] = None, 44 | verification_key: Optional[Union[Ed25519PublicKey, str, bytes]] = None, 45 | ): 46 | """Initialize a Dispatch endpoint, and integrate it into a FastAPI app. 47 | 48 | It mounts a sub-app that implements the Dispatch gRPC interface. 49 | 50 | Args: 51 | app: The FastAPI app to configure. 52 | 53 | registry: A registry of functions to expose. If omitted, the default 54 | registry is used. 55 | 56 | verification_key: Key to use when verifying signed requests. Uses 57 | the value of the DISPATCH_VERIFICATION_KEY environment variable 58 | if omitted. The environment variable is expected to carry an 59 | Ed25519 public key in base64 or PEM format. 60 | If not set, request signature verification is disabled (a warning 61 | will be logged by the constructor). 62 | 63 | Raises: 64 | ValueError: If any of the required arguments are missing. 65 | """ 66 | if not app: 67 | raise ValueError( 68 | "missing FastAPI app as first argument of the Dispatch constructor" 69 | ) 70 | super().__init__(registry, verification_key) 71 | function_service = fastapi.FastAPI() 72 | 73 | @function_service.exception_handler(FunctionServiceError) 74 | async def on_error(request: fastapi.Request, exc: FunctionServiceError): 75 | # https://connectrpc.com/docs/protocol/#error-end-stream 76 | return fastapi.responses.JSONResponse( 77 | status_code=exc.status, 78 | content={"code": exc.code, "message": exc.message}, 79 | ) 80 | 81 | @function_service.post( 82 | # The endpoint for execution is hardcoded at the moment. If the service 83 | # gains more endpoints, this should be turned into a dynamic dispatch 84 | # like the official gRPC server does. 85 | "/Run", 86 | ) 87 | async def run(request: fastapi.Request): 88 | valid, reason = validate_content_length( 89 | int(request.headers.get("content-length", 0)) 90 | ) 91 | if not valid: 92 | raise FunctionServiceError(400, "invalid_argument", reason) 93 | 94 | # Raw request body bytes are only available through the underlying 95 | # starlette Request object's body method, which returns an awaitable, 96 | # forcing execute() to be async. 97 | data: bytes = await request.body() 98 | 99 | content = await self.run( 100 | str(request.url), 101 | request.method, 102 | request.headers, 103 | await request.body(), 104 | ) 105 | 106 | return fastapi.Response(content=content, media_type="application/proto") 107 | 108 | app.mount("/dispatch.sdk.v1.FunctionService", function_service) 109 | -------------------------------------------------------------------------------- /src/dispatch/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | 6 | @dataclass 7 | class NamedValueFromEnvironment: 8 | _envvar: str 9 | _name: str 10 | _value: str 11 | _from_envvar: bool 12 | 13 | def __init__( 14 | self, 15 | envvar: str, 16 | name: str, 17 | value: Optional[str] = None, 18 | from_envvar: bool = False, 19 | ): 20 | self._envvar = envvar 21 | self._name = name 22 | self._from_envvar = from_envvar 23 | if value is None: 24 | self._value = os.environ.get(envvar) or "" 25 | self._from_envvar = True 26 | else: 27 | self._value = value 28 | 29 | def __str__(self): 30 | return self.value 31 | 32 | def __getstate__(self): 33 | return (self._envvar, self._name, self._value, self._from_envvar) 34 | 35 | def __setstate__(self, state): 36 | (self._envvar, self._name, self._value, self._from_envvar) = state 37 | if self._from_envvar: 38 | self._value = os.environ.get(self._envvar) or "" 39 | self._from_envvar = True 40 | 41 | @property 42 | def name(self) -> str: 43 | return self._envvar if self._from_envvar else self._name 44 | 45 | @property 46 | def value(self) -> str: 47 | return self._value 48 | 49 | @value.setter 50 | def value(self, value: str): 51 | self._value = value 52 | self._from_envvar = False 53 | -------------------------------------------------------------------------------- /src/dispatch/coroutine.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from types import coroutine 3 | from typing import Any, Awaitable, List, Tuple 4 | 5 | from dispatch.experimental.durable import durable 6 | from dispatch.proto import Call 7 | 8 | 9 | @coroutine 10 | @durable 11 | def call(call: Call) -> Any: 12 | """Make an asynchronous function call and return its result. If the 13 | function call fails with an error, the error is raised.""" 14 | return (yield call) 15 | 16 | 17 | @coroutine 18 | @durable 19 | def gather(*awaitables: Awaitable[Any]) -> List[Any]: # type: ignore[misc] 20 | """Alias for all.""" 21 | return all(*awaitables) 22 | 23 | 24 | @coroutine 25 | @durable 26 | def all(*awaitables: Awaitable[Any]) -> List[Any]: # type: ignore[misc] 27 | """Concurrently run a set of coroutines, blocking until all coroutines 28 | return or any coroutine raises an error. If any coroutine fails with an 29 | uncaught exception, the exception will be re-raised here.""" 30 | return (yield AllDirective(awaitables)) 31 | 32 | 33 | @coroutine 34 | @durable 35 | def any(*awaitables: Awaitable[Any]) -> List[Any]: # type: ignore[misc] 36 | """Concurrently run a set of coroutines, blocking until any coroutine 37 | returns or all coroutines raises an error. If all coroutines fail with 38 | uncaught exceptions, the exception(s) will be re-raised here.""" 39 | return (yield AnyDirective(awaitables)) 40 | 41 | 42 | @coroutine 43 | @durable 44 | def race(*awaitables: Awaitable[Any]) -> List[Any]: # type: ignore[misc] 45 | """Concurrently run a set of coroutines, blocking until any coroutine 46 | returns or raises an error. If any coroutine fails with an uncaught 47 | exception, the exception will be re-raised here.""" 48 | return (yield RaceDirective(awaitables)) 49 | 50 | 51 | @dataclass 52 | class AllDirective: 53 | awaitables: Tuple[Awaitable[Any], ...] 54 | 55 | 56 | @dataclass 57 | class AnyDirective: 58 | awaitables: Tuple[Awaitable[Any], ...] 59 | 60 | 61 | @dataclass 62 | class RaceDirective: 63 | awaitables: Tuple[Awaitable[Any], ...] 64 | 65 | 66 | class AnyException(RuntimeError): 67 | """Error indicating that all coroutines passed to any() failed 68 | with an exception.""" 69 | 70 | __slots__ = ("exceptions",) 71 | 72 | def __init__(self, exceptions: List[Exception]): 73 | self.exceptions = exceptions 74 | 75 | def __str__(self): 76 | return f"{len(self.exceptions)} coroutine(s) failed with an exception" 77 | -------------------------------------------------------------------------------- /src/dispatch/error.py: -------------------------------------------------------------------------------- 1 | from builtins import TimeoutError as _TimeoutError 2 | from typing import cast 3 | 4 | from dispatch.status import Status, register_error_type 5 | 6 | 7 | class DispatchError(Exception): 8 | """Base class for Dispatch exceptions.""" 9 | 10 | _status = Status.UNSPECIFIED 11 | 12 | 13 | class TimeoutError(DispatchError, _TimeoutError): 14 | """Operation timed out.""" 15 | 16 | _status = Status.TIMEOUT 17 | 18 | 19 | class ThrottleError(DispatchError): 20 | """Operation was throttled.""" 21 | 22 | _status = Status.THROTTLED 23 | 24 | 25 | class InvalidArgumentError(DispatchError, ValueError): 26 | """Invalid argument was received.""" 27 | 28 | _status = Status.INVALID_ARGUMENT 29 | 30 | 31 | class InvalidResponseError(DispatchError, ValueError): 32 | """Invalid response was received.""" 33 | 34 | _status = Status.INVALID_RESPONSE 35 | 36 | 37 | class TemporaryError(DispatchError): 38 | """Generic temporary error. Used in cases where a more specific 39 | error class is not available, but the operation that failed should 40 | be attempted again.""" 41 | 42 | _status = Status.TEMPORARY_ERROR 43 | 44 | 45 | class PermanentError(DispatchError): 46 | """Generic permanent error. Used in cases where a more specific 47 | error class is not available, but the operation that failed should 48 | *not* be attempted again.""" 49 | 50 | _status = Status.PERMANENT_ERROR 51 | 52 | 53 | class IncompatibleStateError(DispatchError): 54 | """Coroutine state is incompatible with the current interpreter 55 | and application revision.""" 56 | 57 | _status = Status.INCOMPATIBLE_STATE 58 | 59 | 60 | class DNSError(DispatchError, ConnectionError): 61 | """Generic DNS error. Used in cases where a more specific error class is 62 | not available, but the operation that failed should be attempted again.""" 63 | 64 | _status = Status.DNS_ERROR 65 | 66 | 67 | class TCPError(DispatchError, ConnectionError): 68 | """Generic TCP error. Used in cases where a more specific error class is 69 | not available, but the operation that failed should be attempted again.""" 70 | 71 | _status = Status.TCP_ERROR 72 | 73 | 74 | class HTTPError(DispatchError, ConnectionError): 75 | """Generic HTTP error. Used in cases where a more specific error class is 76 | not available, but the operation that failed should be attempted again.""" 77 | 78 | _status = Status.HTTP_ERROR 79 | 80 | 81 | class UnauthenticatedError(DispatchError): 82 | """The caller did not authenticate with the resource.""" 83 | 84 | _status = Status.UNAUTHENTICATED 85 | 86 | 87 | class PermissionDeniedError(DispatchError, PermissionError): 88 | """The caller does not have access to the resource.""" 89 | 90 | _status = Status.PERMISSION_DENIED 91 | 92 | 93 | class NotFoundError(DispatchError): 94 | """Generic not found error. Used in cases where a more specific error class 95 | is not available, but the operation that failed should *not* be attempted 96 | again.""" 97 | 98 | _status = Status.NOT_FOUND 99 | 100 | 101 | def dispatch_error_status(error: Exception) -> Status: 102 | return cast(DispatchError, error)._status 103 | 104 | 105 | register_error_type(DispatchError, dispatch_error_status) 106 | -------------------------------------------------------------------------------- /src/dispatch/experimental/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/experimental/__init__.py -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/README.md: -------------------------------------------------------------------------------- 1 | This package provides a `@durable` decorator that can be applied to 2 | generator functions and async functions. The generator and coroutine 3 | instances they create are serializable (can be [pickled][pickle]). 4 | 5 | 6 | [pickle]: https://docs.python.org/3/library/pickle.html 7 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/__init__.py: -------------------------------------------------------------------------------- 1 | """A decorator that makes generators and coroutines serializable. 2 | 3 | This module defines a @durable decorator that can be applied to generator 4 | functions and async functions. The generator and coroutine instances 5 | they create can be pickled. 6 | 7 | Example usage: 8 | 9 | import pickle 10 | from dispatch.experimental.durable import durable 11 | 12 | @durable 13 | def my_generator(): 14 | for i in range(3): 15 | yield i 16 | 17 | # Run the generator to its first yield point: 18 | g = my_generator() 19 | print(next(g)) # 0 20 | 21 | # Make a copy, and consume the remaining items: 22 | b = pickle.dumps(g) 23 | g2 = pickle.loads(b) 24 | print(next(g2)) # 1 25 | print(next(g2)) # 2 26 | 27 | # The original is not affected: 28 | print(next(g)) # 1 29 | print(next(g)) # 2 30 | """ 31 | 32 | from .function import durable 33 | 34 | __all__ = ["durable"] 35 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame.pyi: -------------------------------------------------------------------------------- 1 | from types import FrameType 2 | from typing import Any, AsyncGenerator, Coroutine, Generator, Tuple, Union 3 | 4 | def get_frame_ip(frame: Union[FrameType, Coroutine, Generator, AsyncGenerator]) -> int: 5 | """Get instruction pointer of a generator or coroutine.""" 6 | 7 | def set_frame_ip( 8 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], ip: int 9 | ): 10 | """Set instruction pointer of a generator or coroutine.""" 11 | 12 | def get_frame_sp(frame: Union[FrameType, Coroutine, Generator, AsyncGenerator]) -> int: 13 | """Get stack pointer of a generator or coroutine.""" 14 | 15 | def set_frame_sp( 16 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], sp: int 17 | ): 18 | """Set stack pointer of a generator or coroutine.""" 19 | 20 | def get_frame_bp(frame: Union[FrameType, Coroutine, Generator, AsyncGenerator]) -> int: 21 | """Get block pointer of a generator or coroutine.""" 22 | 23 | def set_frame_bp( 24 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], bp: int 25 | ): 26 | """Set block pointer of a generator or coroutine.""" 27 | 28 | def get_frame_stack_at( 29 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], index: int 30 | ) -> Tuple[bool, Any]: 31 | """Get an object from a generator or coroutine's stack, as an (is_null, obj) tuple.""" 32 | 33 | def set_frame_stack_at( 34 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], 35 | index: int, 36 | unset: bool, 37 | value: Any, 38 | ): 39 | """Set or unset an object on the stack of a generator or coroutine.""" 40 | 41 | def get_frame_block_at( 42 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], index: int 43 | ) -> Tuple[int, int, int]: 44 | """Get a block from a generator or coroutine.""" 45 | 46 | def set_frame_block_at( 47 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], 48 | index: int, 49 | value: Tuple[int, int, int], 50 | ): 51 | """Restore a block of a generator or coroutine.""" 52 | 53 | def get_frame_state( 54 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], 55 | ) -> int: 56 | """Get frame state of a generator or coroutine.""" 57 | 58 | def set_frame_state( 59 | frame: Union[FrameType, Coroutine, Generator, AsyncGenerator], state: int 60 | ): 61 | """Set frame state of a generator or coroutine.""" 62 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame308.h: -------------------------------------------------------------------------------- 1 | // This is a redefinition of the private/opaque frame object. 2 | // 3 | // https://github.com/python/cpython/blob/3.8/Include/frameobject.h#L16 4 | // 5 | // In Python <= 3.10, `struct _frame` is both the PyFrameObject and 6 | // PyInterpreterFrame. From Python 3.11 onwards, the two were split with the 7 | // PyFrameObject (struct _frame) pointing to struct _PyInterpreterFrame. 8 | struct Frame { 9 | PyObject_VAR_HEAD 10 | struct Frame *f_back; // struct _frame 11 | PyCodeObject *f_code; 12 | PyObject *f_builtins; 13 | PyObject *f_globals; 14 | PyObject *f_locals; 15 | PyObject **f_valuestack; 16 | PyObject **f_stacktop; 17 | PyObject *f_trace; 18 | char f_trace_lines; 19 | char f_trace_opcodes; 20 | PyObject *f_gen; 21 | int f_lasti; 22 | int f_lineno; 23 | int f_iblock; 24 | char f_executing; 25 | PyTryBlock f_blockstack[CO_MAXBLOCKS]; 26 | PyObject *f_localsplus[1]; 27 | }; 28 | 29 | // Python 3.9 and prior didn't have an explicit enum of frame states, 30 | // but we can derive them based on the presence of a frame, and other 31 | // information found on the frame, for compatibility with later versions. 32 | typedef enum _framestate { 33 | FRAME_CREATED = -2, 34 | FRAME_EXECUTING = 0, 35 | FRAME_CLEARED = 4 36 | } FrameState; 37 | 38 | /* 39 | // This is the definition of PyGenObject for reference to developers 40 | // working on the extension. 41 | // 42 | // Note that PyCoroObject and PyAsyncGenObject have the same layout as 43 | // PyGenObject, however the struct fields have a cr_ and ag_ prefix 44 | // (respectively) rather than a gi_ prefix. In Python <= 3.10, PyCoroObject 45 | // and PyAsyncGenObject have extra fields compared to PyGenObject. In Python 46 | // 3.11 onwards, the three objects are identical (except for field name 47 | // prefixes). The extra fields in Python <= 3.10 are not applicable to the 48 | // extension at this time. 49 | // 50 | // https://github.com/python/cpython/blob/3.8/Include/genobject.h#L17 51 | typedef struct { 52 | PyObject_HEAD 53 | PyFrameObject *gi_frame; 54 | char gi_running; 55 | PyObject *gi_code; 56 | PyObject *gi_weakreflist; 57 | PyObject *gi_name; 58 | PyObject *gi_qualname; 59 | _PyErr_StackItem gi_exc_state; 60 | } PyGenObject; 61 | */ 62 | 63 | static Frame *get_frame(PyGenObject *gen_like) { 64 | Frame *frame = (Frame *)(gen_like->gi_frame); 65 | assert(frame); 66 | return frame; 67 | } 68 | 69 | static PyCodeObject *get_frame_code(Frame *frame) { 70 | PyCodeObject *code = frame->f_code; 71 | assert(code); 72 | return code; 73 | } 74 | 75 | static int get_frame_lasti(Frame *frame) { 76 | return frame->f_lasti; 77 | } 78 | 79 | static void set_frame_lasti(Frame *frame, int lasti) { 80 | frame->f_lasti = lasti; 81 | } 82 | 83 | static int get_frame_state(PyGenObject *gen_like) { 84 | // Python 3.8 doesn't have frame states, but we can derive 85 | // some for compatibility with later versions and to simplify 86 | // the extension. 87 | Frame *frame = (Frame *)(gen_like->gi_frame); 88 | if (!frame) { 89 | return FRAME_CLEARED; 90 | } 91 | return frame->f_executing ? FRAME_EXECUTING : FRAME_CREATED; 92 | } 93 | 94 | static void set_frame_state(PyGenObject *gen_like, int fs) { 95 | Frame *frame = get_frame(gen_like); 96 | frame->f_executing = (fs == FRAME_EXECUTING); 97 | } 98 | 99 | static int valid_frame_state(int fs) { 100 | return fs == FRAME_CREATED || fs == FRAME_EXECUTING || fs == FRAME_CLEARED; 101 | } 102 | 103 | static int get_frame_stacktop_limit(Frame *frame) { 104 | PyCodeObject *code = get_frame_code(frame); 105 | return code->co_stacksize + code->co_nlocals; 106 | } 107 | 108 | static int get_frame_stacktop(Frame *frame) { 109 | assert(frame->f_localsplus); 110 | int stacktop = (int)(frame->f_stacktop - frame->f_localsplus); 111 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 112 | return stacktop; 113 | } 114 | 115 | static void set_frame_stacktop(Frame *frame, int stacktop) { 116 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 117 | assert(frame->f_localsplus); 118 | frame->f_stacktop = frame->f_localsplus + stacktop; 119 | } 120 | 121 | static PyObject **get_frame_localsplus(Frame *frame) { 122 | PyObject **localsplus = frame->f_localsplus; 123 | assert(localsplus); 124 | return localsplus; 125 | } 126 | 127 | static int get_frame_iblock_limit(Frame *frame) { 128 | return CO_MAXBLOCKS; 129 | } 130 | 131 | static int get_frame_iblock(Frame *frame) { 132 | return frame->f_iblock; 133 | } 134 | 135 | static void set_frame_iblock(Frame *frame, int iblock) { 136 | assert(iblock >= 0 && iblock < get_frame_iblock_limit(frame)); 137 | frame->f_iblock = iblock; 138 | } 139 | 140 | static PyTryBlock *get_frame_blockstack(Frame *frame) { 141 | PyTryBlock *blockstack = frame->f_blockstack; 142 | assert(blockstack); 143 | return blockstack; 144 | } 145 | 146 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame309.h: -------------------------------------------------------------------------------- 1 | // This is a redefinition of the private/opaque frame object. 2 | // https://github.com/python/cpython/blob/3.9/Include/cpython/frameobject.h#L17 3 | // 4 | // In Python <= 3.10, `struct _frame` is both the PyFrameObject and 5 | // PyInterpreterFrame. From Python 3.11 onwards, the two were split with the 6 | // PyFrameObject (struct _frame) pointing to struct _PyInterpreterFrame. 7 | struct Frame { 8 | PyObject_VAR_HEAD 9 | struct Frame *f_back; // struct _frame 10 | PyCodeObject *f_code; 11 | PyObject *f_builtins; 12 | PyObject *f_globals; 13 | PyObject *f_locals; 14 | PyObject **f_valuestack; 15 | PyObject **f_stacktop; 16 | PyObject *f_trace; 17 | char f_trace_lines; 18 | char f_trace_opcodes; 19 | PyObject *f_gen; 20 | int f_lasti; 21 | int f_lineno; 22 | int f_iblock; 23 | char f_executing; 24 | PyTryBlock f_blockstack[CO_MAXBLOCKS]; 25 | PyObject *f_localsplus[1]; 26 | }; 27 | 28 | // Python 3.9 and prior didn't have an explicit enum of frame states, 29 | // but we can derive them based on the presence of a frame, and other 30 | // information found on the frame, for compatibility with later versions. 31 | typedef enum _framestate { 32 | FRAME_CREATED = -2, 33 | FRAME_EXECUTING = 0, 34 | FRAME_CLEARED = 4 35 | } FrameState; 36 | 37 | /* 38 | // This is the definition of PyGenObject for reference to developers 39 | // working on the extension. 40 | // 41 | // Note that PyCoroObject and PyAsyncGenObject have the same layout as 42 | // PyGenObject, however the struct fields have a cr_ and ag_ prefix 43 | // (respectively) rather than a gi_ prefix. In Python <= 3.10, PyCoroObject 44 | // and PyAsyncGenObject have extra fields compared to PyGenObject. In Python 45 | // 3.11 onwards, the three objects are identical (except for field name 46 | // prefixes). The extra fields in Python <= 3.10 are not applicable to the 47 | // extension at this time. 48 | // 49 | // https://github.com/python/cpython/blob/3.9/Include/genobject.h#L15 50 | typedef struct { 51 | PyObject_HEAD 52 | PyFrameObject *gi_frame; 53 | char gi_running; 54 | PyObject *gi_code; 55 | PyObject *gi_weakreflist; 56 | PyObject *gi_name; 57 | PyObject *gi_qualname; 58 | _PyErr_StackItem gi_exc_state; 59 | } PyGenObject; 60 | */ 61 | 62 | static Frame *get_frame(PyGenObject *gen_like) { 63 | Frame *frame = (Frame *)(gen_like->gi_frame); 64 | assert(frame); 65 | return frame; 66 | } 67 | 68 | static PyCodeObject *get_frame_code(Frame *frame) { 69 | PyCodeObject *code = frame->f_code; 70 | assert(code); 71 | return code; 72 | } 73 | 74 | static int get_frame_lasti(Frame *frame) { 75 | return frame->f_lasti; 76 | } 77 | 78 | static void set_frame_lasti(Frame *frame, int lasti) { 79 | frame->f_lasti = lasti; 80 | } 81 | 82 | static int get_frame_state(PyGenObject *gen_like) { 83 | // Python 3.9 doesn't have frame states, but we can derive 84 | // some for compatibility with later versions and to simplify 85 | // the extension. 86 | Frame *frame = (Frame *)(gen_like->gi_frame); 87 | if (!frame) { 88 | return FRAME_CLEARED; 89 | } 90 | return frame->f_executing ? FRAME_EXECUTING : FRAME_CREATED; 91 | } 92 | 93 | static void set_frame_state(PyGenObject *gen_like, int fs) { 94 | Frame *frame = get_frame(gen_like); 95 | frame->f_executing = (fs == FRAME_EXECUTING); 96 | } 97 | 98 | static int valid_frame_state(int fs) { 99 | return fs == FRAME_CREATED || fs == FRAME_EXECUTING || fs == FRAME_CLEARED; 100 | } 101 | 102 | static int get_frame_stacktop_limit(Frame *frame) { 103 | PyCodeObject *code = get_frame_code(frame); 104 | return code->co_stacksize + code->co_nlocals; 105 | } 106 | 107 | static int get_frame_stacktop(Frame *frame) { 108 | assert(frame->f_localsplus); 109 | int stacktop = (int)(frame->f_stacktop - frame->f_localsplus); 110 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 111 | return stacktop; 112 | } 113 | 114 | static void set_frame_stacktop(Frame *frame, int stacktop) { 115 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 116 | assert(frame->f_localsplus); 117 | frame->f_stacktop = frame->f_localsplus + stacktop; 118 | } 119 | 120 | static PyObject **get_frame_localsplus(Frame *frame) { 121 | PyObject **localsplus = frame->f_localsplus; 122 | assert(localsplus); 123 | return localsplus; 124 | } 125 | 126 | static int get_frame_iblock_limit(Frame *frame) { 127 | return CO_MAXBLOCKS; 128 | } 129 | 130 | static int get_frame_iblock(Frame *frame) { 131 | return frame->f_iblock; 132 | } 133 | 134 | static void set_frame_iblock(Frame *frame, int iblock) { 135 | assert(iblock >= 0 && iblock < get_frame_iblock_limit(frame)); 136 | frame->f_iblock = iblock; 137 | } 138 | 139 | static PyTryBlock *get_frame_blockstack(Frame *frame) { 140 | PyTryBlock *blockstack = frame->f_blockstack; 141 | assert(blockstack); 142 | return blockstack; 143 | } 144 | 145 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame310.h: -------------------------------------------------------------------------------- 1 | // This is a redefinition of the private PyFrameState. 2 | // https://github.com/python/cpython/blob/3.10/Include/cpython/frameobject.h#L20 3 | typedef int8_t PyFrameState; 4 | 5 | // This is a redefinition of the private/opaque frame object. 6 | // https://github.com/python/cpython/blob/3.10/Include/cpython/frameobject.h#L28 7 | // 8 | // In Python 3.10, `struct _frame` is both the PyFrameObject and 9 | // PyInterpreterFrame. From Python 3.11 onwards, the two were split with the 10 | // PyFrameObject (struct _frame) pointing to struct _PyInterpreterFrame. 11 | struct Frame { 12 | PyObject_VAR_HEAD 13 | struct Frame *f_back; // struct _frame 14 | PyCodeObject *f_code; 15 | PyObject *f_builtins; 16 | PyObject *f_globals; 17 | PyObject *f_locals; 18 | PyObject **f_valuestack; 19 | PyObject *f_trace; 20 | int f_stackdepth; 21 | char f_trace_lines; 22 | char f_trace_opcodes; 23 | PyObject *f_gen; 24 | int f_lasti; 25 | int f_lineno; 26 | int f_iblock; 27 | PyFrameState f_state; 28 | PyTryBlock f_blockstack[CO_MAXBLOCKS]; 29 | PyObject *f_localsplus[1]; 30 | }; 31 | 32 | // This is a redefinition of private frame state constants. 33 | // https://github.com/python/cpython/blob/3.10/Include/cpython/frameobject.h#L10 34 | typedef enum _framestate { 35 | FRAME_CREATED = -2, 36 | FRAME_SUSPENDED = -1, 37 | FRAME_EXECUTING = 0, 38 | FRAME_RETURNED = 1, 39 | FRAME_UNWINDING = 2, 40 | FRAME_RAISED = 3, 41 | FRAME_CLEARED = 4 42 | } FrameState; 43 | 44 | /* 45 | // This is the definition of PyGenObject for reference to developers 46 | // working on the extension. 47 | // 48 | // Note that PyCoroObject and PyAsyncGenObject have the same layout as 49 | // PyGenObject, however the struct fields have a cr_ and ag_ prefix 50 | // (respectively) rather than a gi_ prefix. In Python 3.10, PyCoroObject 51 | // and PyAsyncGenObject have extra fields compared to PyGenObject. In Python 52 | // 3.11 onwards, the three objects are identical (except for field name 53 | // prefixes). The extra fields in Python 3.10 are not applicable to this 54 | // extension at this time. 55 | // 56 | // https://github.com/python/cpython/blob/3.10/Include/genobject.h#L16 57 | typedef struct { 58 | PyObject_HEAD 59 | PyFrameObject *gi_frame; 60 | PyObject *gi_code; 61 | PyObject *gi_weakreflist; 62 | PyObject *gi_name; 63 | PyObject *gi_qualname; 64 | _PyErr_StackItem gi_exc_state; 65 | } PyGenObject; 66 | */ 67 | 68 | static Frame *get_frame(PyGenObject *gen_like) { 69 | Frame *frame = (Frame *)(gen_like->gi_frame); 70 | assert(frame); 71 | return frame; 72 | } 73 | 74 | static PyCodeObject *get_frame_code(Frame *frame) { 75 | PyCodeObject *code = frame->f_code; 76 | assert(code); 77 | return code; 78 | } 79 | 80 | static int get_frame_lasti(Frame *frame) { 81 | return frame->f_lasti; 82 | } 83 | 84 | static void set_frame_lasti(Frame *frame, int lasti) { 85 | frame->f_lasti = lasti; 86 | } 87 | 88 | static int get_frame_state(PyGenObject *gen_like) { 89 | Frame *frame = (Frame *)(gen_like->gi_frame); 90 | if (!frame) { 91 | return FRAME_CLEARED; 92 | } 93 | return frame->f_state; 94 | } 95 | 96 | static void set_frame_state(PyGenObject *gen_like, int fs) { 97 | Frame *frame = get_frame(gen_like); 98 | frame->f_state = (PyFrameState)fs; 99 | } 100 | 101 | static int valid_frame_state(int fs) { 102 | return fs == FRAME_CREATED || fs == FRAME_SUSPENDED || fs == FRAME_EXECUTING || fs == FRAME_RETURNED || fs == FRAME_UNWINDING || fs == FRAME_RAISED || fs == FRAME_CLEARED; 103 | } 104 | 105 | static int get_frame_stacktop_limit(Frame *frame) { 106 | PyCodeObject *code = get_frame_code(frame); 107 | return code->co_stacksize + code->co_nlocals; 108 | } 109 | 110 | static int get_frame_stacktop(Frame *frame) { 111 | assert(frame->f_localsplus); 112 | assert(frame->f_valuestack); 113 | int stacktop = (int)(frame->f_valuestack - frame->f_localsplus) + frame->f_stackdepth; 114 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 115 | return stacktop; 116 | } 117 | 118 | static void set_frame_stacktop(Frame *frame, int stacktop) { 119 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 120 | assert(frame->f_localsplus); 121 | assert(frame->f_valuestack); 122 | int base = (int)(frame->f_valuestack - frame->f_localsplus); 123 | assert(stacktop >= base); 124 | frame->f_stackdepth = stacktop - base; 125 | } 126 | 127 | static PyObject **get_frame_localsplus(Frame *frame) { 128 | PyObject **localsplus = frame->f_localsplus; 129 | assert(localsplus); 130 | return localsplus; 131 | } 132 | 133 | static int get_frame_iblock_limit(Frame *frame) { 134 | return CO_MAXBLOCKS; 135 | } 136 | 137 | static int get_frame_iblock(Frame *frame) { 138 | return frame->f_iblock; 139 | } 140 | 141 | static void set_frame_iblock(Frame *frame, int iblock) { 142 | assert(iblock >= 0 && iblock < get_frame_iblock_limit(frame)); 143 | frame->f_iblock = iblock; 144 | } 145 | 146 | static PyTryBlock *get_frame_blockstack(Frame *frame) { 147 | PyTryBlock *blockstack = frame->f_blockstack; 148 | assert(blockstack); 149 | return blockstack; 150 | } 151 | 152 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame311.h: -------------------------------------------------------------------------------- 1 | // This is a redefinition of the private/opaque frame object. 2 | // https://github.com/python/cpython/blob/3.11/Include/internal/pycore_frame.h#L47 3 | // 4 | // In Python 3.10 and prior, `struct _frame` is both the PyFrameObject and 5 | // PyInterpreterFrame. From Python 3.11 onwards, the two were split with the 6 | // PyFrameObject (struct _frame) pointing to struct _PyInterpreterFrame. 7 | struct Frame { 8 | PyFunctionObject *f_func; 9 | PyObject *f_globals; 10 | PyObject *f_builtins; 11 | PyObject *f_locals; 12 | PyCodeObject *f_code; 13 | PyFrameObject *frame_obj; 14 | struct Frame *previous; // struct _PyInterpreterFrame 15 | _Py_CODEUNIT *prev_instr; 16 | int stacktop; 17 | bool is_entry; 18 | char owner; 19 | PyObject *localsplus[1]; 20 | }; 21 | 22 | // This is a redefinition of private frame state constants. 23 | // https://github.com/python/cpython/blob/3.11/Include/internal/pycore_frame.h#L33 24 | typedef enum _framestate { 25 | FRAME_CREATED = -2, 26 | FRAME_SUSPENDED = -1, 27 | FRAME_EXECUTING = 0, 28 | FRAME_COMPLETED = 1, 29 | FRAME_CLEARED = 4 30 | } FrameState; 31 | 32 | /* 33 | // This is the definition of PyFrameObject (aka. struct _frame) for reference 34 | // to developers working on the extension. 35 | // 36 | // https://github.com/python/cpython/blob/3.11/Include/internal/pycore_frame.h#L15 37 | typedef struct { 38 | PyObject_HEAD 39 | PyFrameObject *f_back; 40 | struct _PyInterpreterFrame *f_frame; 41 | PyObject *f_trace; 42 | int f_lineno; 43 | char f_trace_lines; 44 | char f_trace_opcodes; 45 | char f_fast_as_locals; 46 | PyObject *_f_frame_data[1]; 47 | } PyFrameObject; 48 | */ 49 | 50 | /* 51 | // This is the definition of PyGenObject for reference to developers 52 | // working on the extension. 53 | // 54 | // Note that PyCoroObject and PyAsyncGenObject have the same layout as 55 | // PyGenObject, however the struct fields have a cr_ and ag_ prefix 56 | // (respectively) rather than a gi_ prefix. 57 | // 58 | // https://github.com/python/cpython/blob/3.11/Include/cpython/genobject.h#L14 59 | typedef struct { 60 | PyObject_HEAD 61 | PyCodeObject *gi_code; 62 | PyObject *gi_weakreflist; 63 | PyObject *gi_name; 64 | PyObject *gi_qualname; 65 | _PyErr_StackItem gi_exc_state; 66 | PyObject *gi_origin_or_finalizer; 67 | char gi_hooks_inited; 68 | char gi_closed; 69 | char gi_running_async; 70 | int8_t gi_frame_state; 71 | PyObject *gi_iframe[1]; 72 | } PyGenObject; 73 | */ 74 | 75 | static Frame *get_frame(PyGenObject *gen_like) { 76 | Frame *frame = (Frame *)(struct _PyInterpreterFrame *)(gen_like->gi_iframe); 77 | assert(frame); 78 | return frame; 79 | } 80 | 81 | static PyCodeObject *get_frame_code(Frame *frame) { 82 | PyCodeObject *code = frame->f_code; 83 | assert(code); 84 | return code; 85 | } 86 | 87 | static int get_frame_lasti(Frame *frame) { 88 | // https://github.com/python/cpython/blob/3.11/Include/internal/pycore_frame.h#L69 89 | PyCodeObject *code = get_frame_code(frame); 90 | assert(frame->prev_instr); 91 | return (int)((intptr_t)frame->prev_instr - (intptr_t)_PyCode_CODE(code)); 92 | } 93 | 94 | static void set_frame_lasti(Frame *frame, int lasti) { 95 | // https://github.com/python/cpython/blob/3.11/Include/internal/pycore_frame.h#L69 96 | PyCodeObject *code = get_frame_code(frame); 97 | frame->prev_instr = (_Py_CODEUNIT *)((intptr_t)_PyCode_CODE(code) + (intptr_t)lasti); 98 | } 99 | 100 | static int get_frame_state(PyGenObject *gen_like) { 101 | return gen_like->gi_frame_state; 102 | } 103 | 104 | static void set_frame_state(PyGenObject *gen_like, int fs) { 105 | gen_like->gi_frame_state = (int8_t)fs; 106 | } 107 | 108 | static int valid_frame_state(int fs) { 109 | return fs == FRAME_CREATED || fs == FRAME_SUSPENDED || fs == FRAME_EXECUTING || fs == FRAME_COMPLETED || fs == FRAME_CLEARED; 110 | } 111 | 112 | static int get_frame_stacktop_limit(Frame *frame) { 113 | PyCodeObject *code = get_frame_code(frame); 114 | return code->co_stacksize + code->co_nlocalsplus; 115 | } 116 | 117 | static int get_frame_stacktop(Frame *frame) { 118 | int stacktop = frame->stacktop; 119 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 120 | return stacktop; 121 | } 122 | 123 | static void set_frame_stacktop(Frame *frame, int stacktop) { 124 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 125 | frame->stacktop = stacktop; 126 | } 127 | 128 | static PyObject **get_frame_localsplus(Frame *frame) { 129 | PyObject **localsplus = frame->localsplus; 130 | assert(localsplus); 131 | return localsplus; 132 | } 133 | 134 | static int get_frame_iblock_limit(Frame *frame) { 135 | return 1; // not applicable >= 3.11 136 | } 137 | 138 | static int get_frame_iblock(Frame *frame) { 139 | return 0; // not applicable >= 3.11 140 | } 141 | 142 | static void set_frame_iblock(Frame *frame, int iblock) { 143 | assert(!iblock); // not applicable >= 3.11 144 | } 145 | 146 | static PyTryBlock *get_frame_blockstack(Frame *frame) { 147 | return NULL; // not applicable >= 3.11 148 | } 149 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame312.h: -------------------------------------------------------------------------------- 1 | // This is a redefinition of the private/opaque frame object. 2 | // 3 | // https://github.com/python/cpython/blob/3.12/Include/internal/pycore_frame.h#L51 4 | // 5 | // In Python 3.10 and prior, `struct _frame` is both the PyFrameObject and 6 | // PyInterpreterFrame. From Python 3.11 onwards, the two were split with the 7 | // PyFrameObject (struct _frame) pointing to struct _PyInterpreterFrame. 8 | struct Frame { 9 | PyCodeObject *f_code; 10 | struct Frame *previous; // struct _PyInterpreterFrame 11 | PyObject *f_funcobj; 12 | PyObject *f_globals; 13 | PyObject *f_builtins; 14 | PyObject *f_locals; 15 | PyFrameObject *frame_obj; 16 | _Py_CODEUNIT *prev_instr; 17 | int stacktop; 18 | uint16_t return_offset; 19 | char owner; 20 | PyObject *localsplus[1]; 21 | }; 22 | 23 | // This is a redefinition of private frame state constants. 24 | // https://github.com/python/cpython/blob/3.12/Include/internal/pycore_frame.h#L34 25 | typedef enum _framestate { 26 | FRAME_CREATED = -2, 27 | FRAME_SUSPENDED = -1, 28 | FRAME_EXECUTING = 0, 29 | FRAME_COMPLETED = 1, 30 | FRAME_CLEARED = 4 31 | } FrameState; 32 | 33 | /* 34 | // This is the definition of PyFrameObject (aka. struct _frame) for reference 35 | // to developers working on the extension. 36 | // 37 | // https://github.com/python/cpython/blob/3.12/Include/internal/pycore_frame.h#L16 38 | typedef struct { 39 | PyObject_HEAD 40 | PyFrameObject *f_back; 41 | struct _PyInterpreterFrame *f_frame; 42 | PyObject *f_trace; 43 | int f_lineno; 44 | char f_trace_lines; 45 | char f_trace_opcodes; 46 | char f_fast_as_locals; 47 | PyObject *_f_frame_data[1]; 48 | } PyFrameObject; 49 | */ 50 | 51 | /* 52 | // This is the definition of PyGenObject for reference to developers 53 | // working on the extension. 54 | // 55 | // Note that PyCoroObject and PyAsyncGenObject have the same layout as 56 | // PyGenObject, however the struct fields have a cr_ and ag_ prefix 57 | // (respectively) rather than a gi_ prefix. 58 | // 59 | // https://github.com/python/cpython/blob/3.12/Include/cpython/genobject.h#L14 60 | typedef struct { 61 | PyObject_HEAD 62 | PyObject *gi_weakreflist; 63 | PyObject *gi_name; 64 | PyObject *gi_qualname; 65 | _PyErr_StackItem gi_exc_state; 66 | PyObject *gi_origin_or_finalizer; 67 | char gi_hooks_inited; 68 | char gi_closed; 69 | char gi_running_async; 70 | int8_t gi_frame_state; 71 | PyObject *gi_iframe[1]; 72 | } PyGenObject; 73 | */ 74 | 75 | static Frame *get_frame(PyGenObject *gen_like) { 76 | Frame *frame = (Frame *)(struct _PyInterpreterFrame *)(gen_like->gi_iframe); 77 | assert(frame); 78 | return frame; 79 | } 80 | 81 | static PyCodeObject *get_frame_code(Frame *frame) { 82 | PyCodeObject *code = frame->f_code; 83 | assert(code); 84 | return code; 85 | } 86 | 87 | static int get_frame_lasti(Frame *frame) { 88 | // https://github.com/python/cpython/blob/3.12/Include/internal/pycore_frame.h#L77 89 | PyCodeObject *code = get_frame_code(frame); 90 | assert(frame->prev_instr); 91 | return (int)((intptr_t)frame->prev_instr - (intptr_t)_PyCode_CODE(code)); 92 | } 93 | 94 | static void set_frame_lasti(Frame *frame, int lasti) { 95 | // https://github.com/python/cpython/blob/3.12/Include/internal/pycore_frame.h#L77 96 | PyCodeObject *code = get_frame_code(frame); 97 | frame->prev_instr = (_Py_CODEUNIT *)((intptr_t)_PyCode_CODE(code) + (intptr_t)lasti); 98 | } 99 | 100 | static int get_frame_state(PyGenObject *gen_like) { 101 | return gen_like->gi_frame_state; 102 | } 103 | 104 | static void set_frame_state(PyGenObject *gen_like, int fs) { 105 | gen_like->gi_frame_state = (int8_t)fs; 106 | } 107 | 108 | static int valid_frame_state(int fs) { 109 | return fs == FRAME_CREATED || fs == FRAME_SUSPENDED || fs == FRAME_EXECUTING || fs == FRAME_COMPLETED || fs == FRAME_CLEARED; 110 | } 111 | 112 | static int get_frame_stacktop_limit(Frame *frame) { 113 | PyCodeObject *code = get_frame_code(frame); 114 | return code->co_stacksize + code->co_nlocalsplus; 115 | } 116 | 117 | static int get_frame_stacktop(Frame *frame) { 118 | int stacktop = frame->stacktop; 119 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 120 | return stacktop; 121 | } 122 | 123 | static void set_frame_stacktop(Frame *frame, int stacktop) { 124 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 125 | frame->stacktop = stacktop; 126 | } 127 | 128 | static PyObject **get_frame_localsplus(Frame *frame) { 129 | PyObject **localsplus = frame->localsplus; 130 | assert(localsplus); 131 | return localsplus; 132 | } 133 | 134 | static int get_frame_iblock_limit(Frame *frame) { 135 | return 1; // not applicable >= 3.11 136 | } 137 | 138 | static int get_frame_iblock(Frame *frame) { 139 | return 0; // not applicable >= 3.11 140 | } 141 | 142 | static void set_frame_iblock(Frame *frame, int iblock) { 143 | assert(!iblock); // not applicable >= 3.11 144 | } 145 | 146 | static PyTryBlock *get_frame_blockstack(Frame *frame) { 147 | return NULL; // not applicable >= 3.11 148 | } 149 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/frame313.h: -------------------------------------------------------------------------------- 1 | // This is a redefinition of the private/opaque frame object. 2 | // https://github.com/python/cpython/blob/v3.13.0a5/Include/internal/pycore_frame.h#L57 3 | // 4 | // In Python 3.10 and prior, `struct _frame` is both the PyFrameObject and 5 | // PyInterpreterFrame. From Python 3.11 onwards, the two were split with the 6 | // PyFrameObject (struct _frame) pointing to struct _PyInterpreterFrame. 7 | struct Frame { 8 | PyObject *f_executable; 9 | struct Frame *previous; // struct _PyInterpreterFrame 10 | PyObject *f_funcobj; 11 | PyObject *f_globals; 12 | PyObject *f_builtins; 13 | PyObject *f_locals; 14 | PyFrameObject *frame_obj; 15 | _Py_CODEUNIT *instr_ptr; 16 | int stacktop; 17 | uint16_t return_offset; 18 | char owner; 19 | PyObject *localsplus[1]; 20 | }; 21 | 22 | // This is a redefinition of private frame state constants. 23 | // https://github.com/python/cpython/blob/v3.13.0a5/Include/internal/pycore_frame.h#L38 24 | typedef enum _framestate { 25 | FRAME_CREATED = -3, 26 | FRAME_SUSPENDED = -2, 27 | FRAME_SUSPENDED_YIELD_FROM = -1, 28 | FRAME_EXECUTING = 0, 29 | FRAME_COMPLETED = 1, 30 | FRAME_CLEARED = 4 31 | } FrameState; 32 | 33 | /* 34 | // This is the definition of PyFrameObject (aka. struct _frame) for reference 35 | // to developers working on the extension. 36 | // 37 | // https://github.com/python/cpython/blob/v3.13.0a5/Include/internal/pycore_frame.h#L20 38 | typedef struct { 39 | PyObject_HEAD 40 | PyFrameObject *f_back; 41 | struct _PyInterpreterFrame *f_frame; 42 | PyObject *f_trace; 43 | int f_lineno; 44 | char f_trace_lines; 45 | char f_trace_opcodes; 46 | char f_fast_as_locals; 47 | PyObject *_f_frame_data[1]; 48 | } PyFrameObject; 49 | */ 50 | 51 | /* 52 | // This is the definition of PyGenObject for reference to developers 53 | // working on the extension. 54 | // 55 | // Note that PyCoroObject and PyAsyncGenObject have the same layout as 56 | // PyGenObject, however the struct fields have a cr_ and ag_ prefix 57 | // (respectively) rather than a gi_ prefix. 58 | // 59 | // https://github.com/python/cpython/blob/v3.13.0a5/Include/cpython/genobject.h#L14 60 | typedef struct { 61 | PyObject_HEAD 62 | PyObject *gi_weakreflist; 63 | PyObject *gi_name; 64 | PyObject *gi_qualname; 65 | _PyErr_StackItem gi_exc_state; 66 | PyObject *gi_origin_or_finalizer; 67 | char gi_hooks_inited; 68 | char gi_closed; 69 | char gi_running_async; 70 | int8_t gi_frame_state; 71 | PyObject *gi_iframe[1]; 72 | } PyGenObject; 73 | */ 74 | 75 | static Frame *get_frame(PyGenObject *gen_like) { 76 | Frame *frame = (Frame *)(struct _PyInterpreterFrame *)(gen_like->gi_iframe); 77 | assert(frame); 78 | return frame; 79 | } 80 | 81 | static PyCodeObject *get_frame_code(Frame *frame) { 82 | PyCodeObject *code = (PyCodeObject *)frame->f_executable; 83 | assert(code); 84 | return code; 85 | } 86 | 87 | static int get_frame_lasti(Frame *frame) { 88 | // https://github.com/python/cpython/blob/v3.13.0a5/Include/internal/pycore_frame.h#L73 89 | PyCodeObject *code = get_frame_code(frame); 90 | assert(frame->instr_ptr); 91 | return (int)((intptr_t)frame->instr_ptr - (intptr_t)_PyCode_CODE(code)); 92 | } 93 | 94 | static void set_frame_lasti(Frame *frame, int lasti) { 95 | // https://github.com/python/cpython/blob/v3.13.0a5/Include/internal/pycore_frame.h#L73 96 | PyCodeObject *code = get_frame_code(frame); 97 | frame->instr_ptr = (_Py_CODEUNIT *)((intptr_t)_PyCode_CODE(code) + (intptr_t)lasti); 98 | } 99 | 100 | static int get_frame_state(PyGenObject *gen_like) { 101 | return gen_like->gi_frame_state; 102 | } 103 | 104 | static void set_frame_state(PyGenObject *gen_like, int fs) { 105 | gen_like->gi_frame_state = (int8_t)fs; 106 | } 107 | 108 | static int valid_frame_state(int fs) { 109 | return fs == FRAME_CREATED || fs == FRAME_SUSPENDED || fs == FRAME_SUSPENDED_YIELD_FROM || fs == FRAME_EXECUTING || fs == FRAME_COMPLETED || fs == FRAME_CLEARED; 110 | } 111 | 112 | static int get_frame_stacktop_limit(Frame *frame) { 113 | PyCodeObject *code = get_frame_code(frame); 114 | return code->co_stacksize + code->co_nlocalsplus; 115 | } 116 | 117 | static int get_frame_stacktop(Frame *frame) { 118 | int stacktop = frame->stacktop; 119 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 120 | return stacktop; 121 | } 122 | 123 | static void set_frame_stacktop(Frame *frame, int stacktop) { 124 | assert(stacktop >= 0 && stacktop < get_frame_stacktop_limit(frame)); 125 | frame->stacktop = stacktop; 126 | } 127 | 128 | static PyObject **get_frame_localsplus(Frame *frame) { 129 | PyObject **localsplus = frame->localsplus; 130 | assert(localsplus); 131 | return localsplus; 132 | } 133 | 134 | static int get_frame_iblock_limit(Frame *frame) { 135 | return 1; // not applicable >= 3.11 136 | } 137 | 138 | static int get_frame_iblock(Frame *frame) { 139 | return 0; // not applicable >= 3.11 140 | } 141 | 142 | static void set_frame_iblock(Frame *frame, int iblock) { 143 | assert(!iblock); // not applicable >= 3.11 144 | } 145 | 146 | static PyTryBlock *get_frame_blockstack(Frame *frame) { 147 | return NULL; // not applicable >= 3.11 148 | } 149 | -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/experimental/durable/py.typed -------------------------------------------------------------------------------- /src/dispatch/experimental/durable/registry.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | from dataclasses import dataclass 3 | from types import FunctionType 4 | from typing import Dict 5 | 6 | 7 | @dataclass 8 | class RegisteredFunction: 9 | """A function that can be referenced in durable state.""" 10 | 11 | fn: FunctionType 12 | key: str 13 | filename: str 14 | lineno: int 15 | hash: str 16 | 17 | def __getstate__(self): 18 | return { 19 | "key": self.key, 20 | "filename": self.filename, 21 | "lineno": self.lineno, 22 | "hash": self.hash, 23 | } 24 | 25 | def __setstate__(self, state): 26 | key, filename, lineno, code_hash = ( 27 | state["key"], 28 | state["filename"], 29 | state["lineno"], 30 | state["hash"], 31 | ) 32 | 33 | rfn = lookup_function(key) 34 | if filename != rfn.filename or lineno != rfn.lineno: 35 | raise ValueError( 36 | f"location mismatch for function {key}: {filename}:{lineno} vs. expected {rfn.filename}:{rfn.lineno}" 37 | ) 38 | elif code_hash != rfn.hash: 39 | raise ValueError( 40 | f"hash mismatch for function {key}: {code_hash} vs. expected {rfn.hash}" 41 | ) 42 | 43 | # mypy 1.10.0 seems to report a false positive here: 44 | # error: Incompatible types in assignment (expression has type "FunctionType", variable has type "MethodType") [assignment] 45 | self.fn = rfn.fn # type: ignore 46 | self.key = key 47 | self.filename = filename 48 | self.lineno = lineno 49 | self.hash = code_hash 50 | 51 | 52 | _REGISTRY: Dict[str, RegisteredFunction] = {} 53 | 54 | 55 | def register_function(fn: FunctionType) -> RegisteredFunction: 56 | """Register a function in the in-memory function registry. 57 | 58 | When serializing a registered function, a reference to the function 59 | is stored along with details about its location and contents. When 60 | deserializing the function, the registry is consulted in order to 61 | find the function associated with the reference (and in order to 62 | check whether the function is the same). 63 | 64 | Args: 65 | fn: The function to register. 66 | 67 | Returns: 68 | str: Unique identifier for the function. 69 | 70 | Raises: 71 | ValueError: The function conflicts with another registered function. 72 | """ 73 | rfn = RegisteredFunction( 74 | key=fn.__qualname__, 75 | fn=fn, 76 | filename=fn.__code__.co_filename, 77 | lineno=fn.__code__.co_firstlineno, 78 | hash="sha256:" + hashlib.sha256(fn.__code__.co_code).hexdigest(), 79 | ) 80 | 81 | try: 82 | existing = _REGISTRY[rfn.key] 83 | except KeyError: 84 | pass 85 | else: 86 | if existing == rfn: 87 | return existing 88 | raise ValueError(f"durable function already registered with key {rfn.key}") 89 | 90 | _REGISTRY[rfn.key] = rfn 91 | return rfn 92 | 93 | 94 | def lookup_function(key: str) -> RegisteredFunction: 95 | """Lookup a registered function by key. 96 | 97 | Args: 98 | key: Unique identifier for the function. 99 | 100 | Returns: 101 | RegisteredFunction: the function that was registered with the specified key. 102 | 103 | Raises: 104 | KeyError: A function has not been registered with this key. 105 | """ 106 | return _REGISTRY[key] 107 | 108 | 109 | def unregister_function(key: str): 110 | """Unregister a function by key. 111 | 112 | Args: 113 | key: Unique identifier for the function. 114 | 115 | Raises: 116 | KeyError: A function has not been registered with this key. 117 | """ 118 | del _REGISTRY[key] 119 | 120 | 121 | def clear_functions(): 122 | """Clear functions clears the registry.""" 123 | _REGISTRY.clear() 124 | -------------------------------------------------------------------------------- /src/dispatch/experimental/lambda_handler.py: -------------------------------------------------------------------------------- 1 | """Integration of Dispatch programmable endpoints for AWS Lambda. 2 | 3 | Example: 4 | 5 | from dispatch.experimental.lambda_handler import Dispatch 6 | 7 | dispatch = Dispatch(api_key="test-key") 8 | 9 | @dispatch.function 10 | def my_function(): 11 | return "Hello World!" 12 | 13 | @dispatch.function 14 | def entrypoint(): 15 | my_function() 16 | 17 | def handler(event, context): 18 | dispatch.handle(event, context, entrypoint="entrypoint") 19 | """ 20 | 21 | import asyncio 22 | import base64 23 | import json 24 | import logging 25 | from typing import Optional 26 | 27 | from awslambdaric.lambda_context import LambdaContext 28 | 29 | from dispatch.function import Registry 30 | from dispatch.http import BlockingFunctionService 31 | from dispatch.proto import Input 32 | from dispatch.sdk.v1 import function_pb2 as function_pb 33 | from dispatch.status import Status 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | class Dispatch(BlockingFunctionService): 39 | def __init__( 40 | self, 41 | registry: Optional[Registry] = None, 42 | ): 43 | """Initializes a Dispatch Lambda handler.""" 44 | # We use a fake endpoint to initialize the base class. The actual endpoint (the Lambda ARN) 45 | # is only known when the handler is invoked. 46 | super().__init__(registry) 47 | 48 | def handle( 49 | self, event: str, context: LambdaContext, entrypoint: Optional[str] = None 50 | ): 51 | # The ARN is not none until the first invocation of the Lambda function. 52 | # We override the endpoint of all registered functions before any execution. 53 | if context.invoked_function_arn: 54 | self.endpoint = context.invoked_function_arn 55 | # TODO: this might mutate the default registry, we should figure out a better way. 56 | self.registry.endpoint = self.endpoint 57 | 58 | if not event: 59 | raise ValueError("event is required") 60 | 61 | try: 62 | raw = base64.b64decode(event) 63 | except Exception as e: 64 | raise ValueError("event is not base64 encoded") from e 65 | 66 | req = function_pb.RunRequest.FromString(raw) 67 | 68 | function: Optional[str] = req.function if req.function else entrypoint 69 | if not function: 70 | raise ValueError("function is required") 71 | 72 | logger.debug( 73 | "Dispatch handler invoked for %s function %s with runRequest: %s", 74 | self.endpoint, 75 | function, 76 | req, 77 | ) 78 | 79 | try: 80 | func = self.registry.functions[req.function] 81 | except KeyError: 82 | raise ValueError(f"function {req.function} not found") 83 | 84 | input = Input(req) 85 | try: 86 | output = asyncio.run(func._primitive_call(input)) 87 | except Exception: 88 | logger.error("function '%s' fatal error", req.function, exc_info=True) 89 | raise # FIXME 90 | else: 91 | response = output._message 92 | status = Status(response.status) 93 | 94 | if response.HasField("poll"): 95 | logger.debug( 96 | "function '%s' polling with %d call(s)", 97 | req.function, 98 | len(response.poll.calls), 99 | ) 100 | elif response.HasField("exit"): 101 | exit = response.exit 102 | if not exit.HasField("result"): 103 | logger.debug("function '%s' exiting with no result", req.function) 104 | else: 105 | result = exit.result 106 | if result.HasField("output"): 107 | logger.debug( 108 | "function '%s' exiting with output value", req.function 109 | ) 110 | elif result.HasField("error"): 111 | err = result.error 112 | logger.debug( 113 | "function '%s' exiting with error: %s (%s)", 114 | req.function, 115 | err.message, 116 | err.type, 117 | ) 118 | if exit.HasField("tail_call"): 119 | logger.debug( 120 | "function '%s' tail calling function '%s'", 121 | exit.tail_call.function, 122 | ) 123 | 124 | logger.debug("finished handling run request with status %s", status.name) 125 | respBytes = response.SerializeToString() 126 | respStr = base64.b64encode(respBytes).decode("utf-8") 127 | return bytes(json.dumps(respStr), "utf-8") 128 | -------------------------------------------------------------------------------- /src/dispatch/fastapi.py: -------------------------------------------------------------------------------- 1 | """Integration of Dispatch functions with FastAPI. 2 | 3 | Example: 4 | 5 | import fastapi 6 | from dispatch.fastapi import Dispatch 7 | 8 | app = fastapi.FastAPI() 9 | dispatch = Dispatch(app) 10 | 11 | @dispatch.function 12 | def my_function(): 13 | return "Hello World!" 14 | 15 | @app.get("/") 16 | def read_root(): 17 | my_function.dispatch() 18 | """ 19 | 20 | from typing import Any, Callable, Coroutine, TypeVar, overload 21 | 22 | from typing_extensions import ParamSpec 23 | 24 | from dispatch.asyncio.fastapi import Dispatch as AsyncDispatch 25 | from dispatch.function import BlockingFunction 26 | 27 | __all__ = ["Dispatch", "AsyncDispatch"] 28 | 29 | P = ParamSpec("P") 30 | T = TypeVar("T") 31 | 32 | 33 | class Dispatch(AsyncDispatch): 34 | @overload # type: ignore 35 | def function(self, func: Callable[P, T]) -> BlockingFunction[P, T]: ... 36 | 37 | @overload # type: ignore 38 | def function( 39 | self, func: Callable[P, Coroutine[Any, Any, T]] 40 | ) -> BlockingFunction[P, T]: ... 41 | 42 | def function(self, func): 43 | return BlockingFunction(super().function(func)) 44 | -------------------------------------------------------------------------------- /src/dispatch/flask.py: -------------------------------------------------------------------------------- 1 | """Integration of Dispatch functions with Flask. 2 | 3 | Example: 4 | 5 | from flask import Flask 6 | from dispatch.flask import Dispatch 7 | 8 | app = Flask(__name__) 9 | dispatch = Dispatch(app) 10 | 11 | @dispatch.function 12 | def my_function(): 13 | return "Hello World!" 14 | 15 | @app.get("/") 16 | def read_root(): 17 | my_function.dispatch() 18 | """ 19 | 20 | import asyncio 21 | import logging 22 | from typing import Optional, Union 23 | 24 | from flask import Flask, make_response, request 25 | 26 | from dispatch.function import Registry 27 | from dispatch.http import ( 28 | BlockingFunctionService, 29 | FunctionServiceError, 30 | validate_content_length, 31 | ) 32 | from dispatch.signature import Ed25519PublicKey, parse_verification_key 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | class Dispatch(BlockingFunctionService): 38 | """A Dispatch instance, powered by Flask.""" 39 | 40 | def __init__( 41 | self, 42 | app: Flask, 43 | registry: Optional[Registry] = None, 44 | verification_key: Optional[Union[Ed25519PublicKey, str, bytes]] = None, 45 | ): 46 | """Initialize a Dispatch endpoint, and integrate it into a Flask app. 47 | 48 | It mounts a sub-app that implements the Dispatch gRPC interface. 49 | 50 | Args: 51 | app: The Flask app to configure. 52 | 53 | registry: A registry of functions to expose. If omitted, the default 54 | registry is used. 55 | 56 | verification_key: Key to use when verifying signed requests. Uses 57 | the value of the DISPATCH_VERIFICATION_KEY environment variable 58 | if omitted. The environment variable is expected to carry an 59 | Ed25519 public key in base64 or PEM format. 60 | If not set, request signature verification is disabled (a warning 61 | will be logged by the constructor). 62 | 63 | Raises: 64 | ValueError: If any of the required arguments are missing. 65 | """ 66 | if not app: 67 | raise ValueError( 68 | "missing Flask app as first argument of the Dispatch constructor" 69 | ) 70 | super().__init__(registry, verification_key) 71 | app.errorhandler(FunctionServiceError)(self._on_error) 72 | app.post("/dispatch.sdk.v1.FunctionService/Run")(self._run) 73 | 74 | def _on_error(self, exc: FunctionServiceError): 75 | return {"code": exc.code, "message": exc.message}, exc.status 76 | 77 | def _run(self): 78 | valid, reason = validate_content_length(request.content_length or 0) 79 | if not valid: 80 | return {"code": "invalid_argument", "message": reason}, 400 81 | 82 | content = asyncio.run( 83 | self.run( 84 | request.url, 85 | request.method, 86 | dict(request.headers), 87 | request.get_data(cache=False), 88 | ) 89 | ) 90 | 91 | res = make_response(content) 92 | res.content_type = "application/proto" 93 | return res 94 | -------------------------------------------------------------------------------- /src/dispatch/id.py: -------------------------------------------------------------------------------- 1 | from typing_extensions import TypeAlias 2 | 3 | DispatchID: TypeAlias = str 4 | """Unique identifier in Dispatch. 5 | 6 | It should be treated as an opaque value. 7 | """ 8 | -------------------------------------------------------------------------------- /src/dispatch/integrations/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import logging 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | # Automatically register error and output types from 8 | # commonly used libraries. 9 | integrations = ("httpx", "requests", "slack", "openai") 10 | for name in integrations: 11 | try: 12 | importlib.import_module(f"dispatch.integrations.{name}") 13 | except (ImportError, AttributeError): 14 | pass 15 | else: 16 | logger.debug("registered %s integration", name) 17 | -------------------------------------------------------------------------------- /src/dispatch/integrations/http.py: -------------------------------------------------------------------------------- 1 | from dispatch.status import Status 2 | 3 | 4 | def http_response_code_status(code: int) -> Status: 5 | """Returns a Status that's broadly equivalent to an HTTP response 6 | status code.""" 7 | if code == 400: # Bad Request 8 | return Status.INVALID_ARGUMENT 9 | elif code == 401: # Unauthorized 10 | return Status.UNAUTHENTICATED 11 | elif code == 403: # Forbidden 12 | return Status.PERMISSION_DENIED 13 | elif code == 404: # Not Found 14 | return Status.NOT_FOUND 15 | elif code == 408: # Request Timeout 16 | return Status.TIMEOUT 17 | elif code == 429: # Too Many Requests 18 | return Status.THROTTLED 19 | elif code == 501: # Not Implemented 20 | return Status.PERMANENT_ERROR 21 | 22 | category = code // 100 23 | if category == 1: # 1xx informational 24 | return Status.PERMANENT_ERROR 25 | elif category == 2: # 2xx success 26 | return Status.OK 27 | elif category == 3: # 3xx redirection 28 | return Status.PERMANENT_ERROR 29 | elif category == 4: # 4xx client error 30 | return Status.PERMANENT_ERROR 31 | elif category == 5: # 5xx server error 32 | return Status.TEMPORARY_ERROR 33 | 34 | return Status.UNSPECIFIED 35 | -------------------------------------------------------------------------------- /src/dispatch/integrations/httpx.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | 3 | from dispatch.integrations.http import http_response_code_status 4 | from dispatch.status import Status, register_error_type, register_output_type 5 | 6 | 7 | def httpx_error_status(error: Exception) -> Status: 8 | # See https://www.python-httpx.org/exceptions/ 9 | if isinstance(error, httpx.HTTPStatusError): 10 | return httpx_response_status(error.response) 11 | elif isinstance(error, httpx.InvalidURL): 12 | return Status.INVALID_ARGUMENT 13 | elif isinstance(error, httpx.UnsupportedProtocol): 14 | return Status.INVALID_ARGUMENT 15 | elif isinstance(error, httpx.TimeoutException): 16 | return Status.TIMEOUT 17 | 18 | return Status.TEMPORARY_ERROR 19 | 20 | 21 | def httpx_response_status(response: httpx.Response) -> Status: 22 | return http_response_code_status(response.status_code) 23 | 24 | 25 | # Register types of things that a function might return. 26 | register_output_type(httpx.Response, httpx_response_status) 27 | 28 | 29 | # Register base exceptions. 30 | register_error_type(httpx.HTTPError, httpx_error_status) 31 | register_error_type(httpx.StreamError, httpx_error_status) 32 | register_error_type(httpx.InvalidURL, httpx_error_status) 33 | register_error_type(httpx.CookieConflict, httpx_error_status) 34 | -------------------------------------------------------------------------------- /src/dispatch/integrations/openai.py: -------------------------------------------------------------------------------- 1 | import openai # type: ignore 2 | 3 | from dispatch.integrations.http import http_response_code_status 4 | from dispatch.status import Status, register_error_type 5 | 6 | 7 | def openai_error_status(error: Exception) -> Status: 8 | # See https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py 9 | if isinstance(error, openai.APITimeoutError): 10 | return Status.TIMEOUT 11 | elif isinstance(error, openai.APIStatusError): 12 | return http_response_code_status(error.status_code) 13 | 14 | return Status.TEMPORARY_ERROR 15 | 16 | 17 | # Register base exception. 18 | register_error_type(openai.OpenAIError, openai_error_status) 19 | -------------------------------------------------------------------------------- /src/dispatch/integrations/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/integrations/py.typed -------------------------------------------------------------------------------- /src/dispatch/integrations/requests.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | from dispatch.integrations.http import http_response_code_status 4 | from dispatch.status import Status, register_error_type, register_output_type 5 | 6 | 7 | def requests_error_status(error: Exception) -> Status: 8 | # See https://requests.readthedocs.io/en/latest/api/#exceptions 9 | # and https://requests.readthedocs.io/en/latest/_modules/requests/exceptions/ 10 | if isinstance(error, requests.HTTPError): 11 | if error.response is not None: 12 | return requests_response_status(error.response) 13 | elif isinstance(error, requests.Timeout): 14 | return Status.TIMEOUT 15 | elif isinstance( 16 | error, ValueError 17 | ): # base class of things like requests.InvalidURL, etc. 18 | return Status.INVALID_ARGUMENT 19 | 20 | return Status.TEMPORARY_ERROR 21 | 22 | 23 | def requests_response_status(response: requests.Response) -> Status: 24 | return http_response_code_status(response.status_code) 25 | 26 | 27 | # Register types of things that a function might return. 28 | register_output_type(requests.Response, requests_response_status) 29 | 30 | # Register base exception. 31 | register_error_type(requests.RequestException, requests_error_status) 32 | -------------------------------------------------------------------------------- /src/dispatch/integrations/slack.py: -------------------------------------------------------------------------------- 1 | import slack_sdk # type: ignore 2 | import slack_sdk.errors # type: ignore 3 | import slack_sdk.web # type: ignore 4 | 5 | from dispatch.integrations.http import http_response_code_status 6 | from dispatch.status import Status, register_error_type, register_output_type 7 | 8 | 9 | def slack_error_status(error: Exception) -> Status: 10 | # See https://github.com/slackapi/python-slack-sdk/blob/main/slack/errors.py 11 | if isinstance(error, slack_sdk.errors.SlackApiError) and error.response is not None: 12 | return slack_response_status(error.response) 13 | 14 | return Status.TEMPORARY_ERROR 15 | 16 | 17 | def slack_response_status(response: slack_sdk.web.SlackResponse) -> Status: 18 | return http_response_code_status(response.status_code) 19 | 20 | 21 | # Register types of things that a function might return. 22 | register_output_type(slack_sdk.web.SlackResponse, slack_response_status) 23 | 24 | # Register base exception. 25 | register_error_type(slack_sdk.errors.SlackClientError, slack_error_status) 26 | -------------------------------------------------------------------------------- /src/dispatch/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/py.typed -------------------------------------------------------------------------------- /src/dispatch/sdk/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/sdk/__init__.py -------------------------------------------------------------------------------- /src/dispatch/sdk/python/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/sdk/python/__init__.py -------------------------------------------------------------------------------- /src/dispatch/sdk/python/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/sdk/python/v1/__init__.py -------------------------------------------------------------------------------- /src/dispatch/sdk/python/v1/pickled_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/python/v1/pickled.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 17 | b'\n$dispatch/sdk/python/v1/pickled.proto\x12\x16\x64ispatch.sdk.python.v1".\n\x07Pickled\x12#\n\rpickled_value\x18\x01 \x01(\x0cR\x0cpickledValueB\xa5\x01\n\x1a\x63om.dispatch.sdk.python.v1B\x0cPickledProtoP\x01\xa2\x02\x03\x44SP\xaa\x02\x16\x44ispatch.Sdk.Python.V1\xca\x02\x16\x44ispatch\\Sdk\\Python\\V1\xe2\x02"Dispatch\\Sdk\\Python\\V1\\GPBMetadata\xea\x02\x19\x44ispatch::Sdk::Python::V1b\x06proto3' 18 | ) 19 | 20 | _globals = globals() 21 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 22 | _builder.BuildTopDescriptorsAndMessages( 23 | DESCRIPTOR, "dispatch.sdk.python.v1.pickled_pb2", _globals 24 | ) 25 | if _descriptor._USE_C_DESCRIPTORS == False: 26 | _globals["DESCRIPTOR"]._options = None 27 | _globals["DESCRIPTOR"]._serialized_options = ( 28 | b'\n\032com.dispatch.sdk.python.v1B\014PickledProtoP\001\242\002\003DSP\252\002\026Dispatch.Sdk.Python.V1\312\002\026Dispatch\\Sdk\\Python\\V1\342\002"Dispatch\\Sdk\\Python\\V1\\GPBMetadata\352\002\031Dispatch::Sdk::Python::V1' 29 | ) 30 | _globals["_PICKLED"]._serialized_start = 64 31 | _globals["_PICKLED"]._serialized_end = 110 32 | # @@protoc_insertion_point(module_scope) 33 | -------------------------------------------------------------------------------- /src/dispatch/sdk/python/v1/pickled_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Optional as _Optional 3 | 4 | from google.protobuf import descriptor as _descriptor 5 | from google.protobuf import message as _message 6 | 7 | DESCRIPTOR: _descriptor.FileDescriptor 8 | 9 | class Pickled(_message.Message): 10 | __slots__ = ("pickled_value",) 11 | PICKLED_VALUE_FIELD_NUMBER: _ClassVar[int] 12 | pickled_value: bytes 13 | def __init__(self, pickled_value: _Optional[bytes] = ...) -> None: ... 14 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/sdk/v1/__init__.py -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/call_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/call.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 17 | from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 18 | 19 | from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2 20 | from dispatch.sdk.v1 import error_pb2 as dispatch_dot_sdk_dot_v1_dot_error__pb2 21 | 22 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 23 | b'\n\x1a\x64ispatch/sdk/v1/call.proto\x12\x0f\x64ispatch.sdk.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1b\x64ispatch/sdk/v1/error.proto\x1a\x19google/protobuf/any.proto\x1a\x1egoogle/protobuf/duration.proto"\xa1\x02\n\x04\x43\x61ll\x12%\n\x0e\x63orrelation_id\x18\x01 \x01(\x04R\rcorrelationId\x12$\n\x08\x65ndpoint\x18\x02 \x01(\tB\x08\xbaH\x05r\x03\x88\x01\x01R\x08\x65ndpoint\x12\x41\n\x08\x66unction\x18\x03 \x01(\tB%\xbaH"r\x1d\x32\x1b^[a-zA-Z_][a-zA-Z0-9_<>.]*$\xc8\x01\x01R\x08\x66unction\x12*\n\x05input\x18\x04 \x01(\x0b\x32\x14.google.protobuf.AnyR\x05input\x12\x43\n\nexpiration\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x08\xbaH\x05\xaa\x01\x02\x32\x00R\nexpiration\x12\x18\n\x07version\x18\x06 \x01(\tR\x07version"\xb0\x01\n\nCallResult\x12%\n\x0e\x63orrelation_id\x18\x01 \x01(\x04R\rcorrelationId\x12,\n\x06output\x18\x02 \x01(\x0b\x32\x14.google.protobuf.AnyR\x06output\x12,\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x16.dispatch.sdk.v1.ErrorR\x05\x65rror\x12\x1f\n\x0b\x64ispatch_id\x18\x04 \x01(\tR\ndispatchIdB~\n\x13\x63om.dispatch.sdk.v1B\tCallProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3' 24 | ) 25 | 26 | _globals = globals() 27 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 28 | _builder.BuildTopDescriptorsAndMessages( 29 | DESCRIPTOR, "dispatch.sdk.v1.call_pb2", _globals 30 | ) 31 | if _descriptor._USE_C_DESCRIPTORS == False: 32 | _globals["DESCRIPTOR"]._options = None 33 | _globals["DESCRIPTOR"]._serialized_options = ( 34 | b"\n\023com.dispatch.sdk.v1B\tCallProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 35 | ) 36 | _globals["_CALL"].fields_by_name["endpoint"]._options = None 37 | _globals["_CALL"].fields_by_name[ 38 | "endpoint" 39 | ]._serialized_options = b"\272H\005r\003\210\001\001" 40 | _globals["_CALL"].fields_by_name["function"]._options = None 41 | _globals["_CALL"].fields_by_name[ 42 | "function" 43 | ]._serialized_options = b'\272H"r\0352\033^[a-zA-Z_][a-zA-Z0-9_<>.]*$\310\001\001' 44 | _globals["_CALL"].fields_by_name["expiration"]._options = None 45 | _globals["_CALL"].fields_by_name[ 46 | "expiration" 47 | ]._serialized_options = b"\272H\005\252\001\0022\000" 48 | _globals["_CALL"]._serialized_start = 165 49 | _globals["_CALL"]._serialized_end = 454 50 | _globals["_CALLRESULT"]._serialized_start = 457 51 | _globals["_CALLRESULT"]._serialized_end = 633 52 | # @@protoc_insertion_point(module_scope) 53 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/call_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Mapping as _Mapping 3 | from typing import Optional as _Optional 4 | from typing import Union as _Union 5 | 6 | from google.protobuf import any_pb2 as _any_pb2 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import duration_pb2 as _duration_pb2 9 | from google.protobuf import message as _message 10 | 11 | from buf.validate import validate_pb2 as _validate_pb2 12 | from dispatch.sdk.v1 import error_pb2 as _error_pb2 13 | 14 | DESCRIPTOR: _descriptor.FileDescriptor 15 | 16 | class Call(_message.Message): 17 | __slots__ = ( 18 | "correlation_id", 19 | "endpoint", 20 | "function", 21 | "input", 22 | "expiration", 23 | "version", 24 | ) 25 | CORRELATION_ID_FIELD_NUMBER: _ClassVar[int] 26 | ENDPOINT_FIELD_NUMBER: _ClassVar[int] 27 | FUNCTION_FIELD_NUMBER: _ClassVar[int] 28 | INPUT_FIELD_NUMBER: _ClassVar[int] 29 | EXPIRATION_FIELD_NUMBER: _ClassVar[int] 30 | VERSION_FIELD_NUMBER: _ClassVar[int] 31 | correlation_id: int 32 | endpoint: str 33 | function: str 34 | input: _any_pb2.Any 35 | expiration: _duration_pb2.Duration 36 | version: str 37 | def __init__( 38 | self, 39 | correlation_id: _Optional[int] = ..., 40 | endpoint: _Optional[str] = ..., 41 | function: _Optional[str] = ..., 42 | input: _Optional[_Union[_any_pb2.Any, _Mapping]] = ..., 43 | expiration: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ..., 44 | version: _Optional[str] = ..., 45 | ) -> None: ... 46 | 47 | class CallResult(_message.Message): 48 | __slots__ = ("correlation_id", "output", "error", "dispatch_id") 49 | CORRELATION_ID_FIELD_NUMBER: _ClassVar[int] 50 | OUTPUT_FIELD_NUMBER: _ClassVar[int] 51 | ERROR_FIELD_NUMBER: _ClassVar[int] 52 | DISPATCH_ID_FIELD_NUMBER: _ClassVar[int] 53 | correlation_id: int 54 | output: _any_pb2.Any 55 | error: _error_pb2.Error 56 | dispatch_id: str 57 | def __init__( 58 | self, 59 | correlation_id: _Optional[int] = ..., 60 | output: _Optional[_Union[_any_pb2.Any, _Mapping]] = ..., 61 | error: _Optional[_Union[_error_pb2.Error, _Mapping]] = ..., 62 | dispatch_id: _Optional[str] = ..., 63 | ) -> None: ... 64 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/dispatch_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/dispatch.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2 17 | from dispatch.sdk.v1 import call_pb2 as dispatch_dot_sdk_dot_v1_dot_call__pb2 18 | 19 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 20 | b"\n\x1e\x64ispatch/sdk/v1/dispatch.proto\x12\x0f\x64ispatch.sdk.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1a\x64ispatch/sdk/v1/call.proto\"\xea\x03\n\x0f\x44ispatchRequest\x12+\n\x05\x63\x61lls\x18\x01 \x03(\x0b\x32\x15.dispatch.sdk.v1.CallR\x05\x63\x61lls:\xa9\x03\xbaH\xa5\x03\x1as\n(dispatch.request.calls.endpoint.nonempty\x12\x1d\x43\x61ll endpoint cannot be empty\x1a(this.calls.all(call, has(call.endpoint))\x1a\xad\x02\n&dispatch.request.calls.endpoint.scheme\x12HCall endpoint must be a http, https or a bridge URL or an AWS Lambda ARN\x1a\xb8\x01this.calls.all(call, call.endpoint.startsWith('http://') || call.endpoint.startsWith('https://') || call.endpoint.startsWith('bridge://') || call.endpoint.startsWith('arn:aws:lambda'))\"5\n\x10\x44ispatchResponse\x12!\n\x0c\x64ispatch_ids\x18\x01 \x03(\tR\x0b\x64ispatchIds2d\n\x0f\x44ispatchService\x12Q\n\x08\x44ispatch\x12 .dispatch.sdk.v1.DispatchRequest\x1a!.dispatch.sdk.v1.DispatchResponse\"\x00\x42\x82\x01\n\x13\x63om.dispatch.sdk.v1B\rDispatchProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3" 21 | ) 22 | 23 | _globals = globals() 24 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 25 | _builder.BuildTopDescriptorsAndMessages( 26 | DESCRIPTOR, "dispatch.sdk.v1.dispatch_pb2", _globals 27 | ) 28 | if _descriptor._USE_C_DESCRIPTORS == False: 29 | _globals["DESCRIPTOR"]._options = None 30 | _globals["DESCRIPTOR"]._serialized_options = ( 31 | b"\n\023com.dispatch.sdk.v1B\rDispatchProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 32 | ) 33 | _globals["_DISPATCHREQUEST"]._options = None 34 | _globals["_DISPATCHREQUEST"]._serialized_options = ( 35 | b"\272H\245\003\032s\n(dispatch.request.calls.endpoint.nonempty\022\035Call endpoint cannot be empty\032(this.calls.all(call, has(call.endpoint))\032\255\002\n&dispatch.request.calls.endpoint.scheme\022HCall endpoint must be a http, https or a bridge URL or an AWS Lambda ARN\032\270\001this.calls.all(call, call.endpoint.startsWith('http://') || call.endpoint.startsWith('https://') || call.endpoint.startsWith('bridge://') || call.endpoint.startsWith('arn:aws:lambda'))" 36 | ) 37 | _globals["_DISPATCHREQUEST"]._serialized_start = 109 38 | _globals["_DISPATCHREQUEST"]._serialized_end = 599 39 | _globals["_DISPATCHRESPONSE"]._serialized_start = 601 40 | _globals["_DISPATCHRESPONSE"]._serialized_end = 654 41 | _globals["_DISPATCHSERVICE"]._serialized_start = 656 42 | _globals["_DISPATCHSERVICE"]._serialized_end = 756 43 | # @@protoc_insertion_point(module_scope) 44 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/dispatch_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Iterable as _Iterable 3 | from typing import Mapping as _Mapping 4 | from typing import Optional as _Optional 5 | from typing import Union as _Union 6 | 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import message as _message 9 | from google.protobuf.internal import containers as _containers 10 | 11 | from buf.validate import validate_pb2 as _validate_pb2 12 | from dispatch.sdk.v1 import call_pb2 as _call_pb2 13 | 14 | DESCRIPTOR: _descriptor.FileDescriptor 15 | 16 | class DispatchRequest(_message.Message): 17 | __slots__ = ("calls",) 18 | CALLS_FIELD_NUMBER: _ClassVar[int] 19 | calls: _containers.RepeatedCompositeFieldContainer[_call_pb2.Call] 20 | def __init__( 21 | self, calls: _Optional[_Iterable[_Union[_call_pb2.Call, _Mapping]]] = ... 22 | ) -> None: ... 23 | 24 | class DispatchResponse(_message.Message): 25 | __slots__ = ("dispatch_ids",) 26 | DISPATCH_IDS_FIELD_NUMBER: _ClassVar[int] 27 | dispatch_ids: _containers.RepeatedScalarFieldContainer[str] 28 | def __init__(self, dispatch_ids: _Optional[_Iterable[str]] = ...) -> None: ... 29 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/error_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/error.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 17 | b'\n\x1b\x64ispatch/sdk/v1/error.proto\x12\x0f\x64ispatch.sdk.v1"i\n\x05\x45rror\x12\x12\n\x04type\x18\x01 \x01(\tR\x04type\x12\x18\n\x07message\x18\x02 \x01(\tR\x07message\x12\x14\n\x05value\x18\x03 \x01(\x0cR\x05value\x12\x1c\n\ttraceback\x18\x04 \x01(\x0cR\ttracebackB\x7f\n\x13\x63om.dispatch.sdk.v1B\nErrorProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3' 18 | ) 19 | 20 | _globals = globals() 21 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 22 | _builder.BuildTopDescriptorsAndMessages( 23 | DESCRIPTOR, "dispatch.sdk.v1.error_pb2", _globals 24 | ) 25 | if _descriptor._USE_C_DESCRIPTORS == False: 26 | _globals["DESCRIPTOR"]._options = None 27 | _globals["DESCRIPTOR"]._serialized_options = ( 28 | b"\n\023com.dispatch.sdk.v1B\nErrorProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 29 | ) 30 | _globals["_ERROR"]._serialized_start = 48 31 | _globals["_ERROR"]._serialized_end = 153 32 | # @@protoc_insertion_point(module_scope) 33 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/error_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Optional as _Optional 3 | 4 | from google.protobuf import descriptor as _descriptor 5 | from google.protobuf import message as _message 6 | 7 | DESCRIPTOR: _descriptor.FileDescriptor 8 | 9 | class Error(_message.Message): 10 | __slots__ = ("type", "message", "value", "traceback") 11 | TYPE_FIELD_NUMBER: _ClassVar[int] 12 | MESSAGE_FIELD_NUMBER: _ClassVar[int] 13 | VALUE_FIELD_NUMBER: _ClassVar[int] 14 | TRACEBACK_FIELD_NUMBER: _ClassVar[int] 15 | type: str 16 | message: str 17 | value: bytes 18 | traceback: bytes 19 | def __init__( 20 | self, 21 | type: _Optional[str] = ..., 22 | message: _Optional[str] = ..., 23 | value: _Optional[bytes] = ..., 24 | traceback: _Optional[bytes] = ..., 25 | ) -> None: ... 26 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/exit_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/exit.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2 17 | from dispatch.sdk.v1 import call_pb2 as dispatch_dot_sdk_dot_v1_dot_call__pb2 18 | 19 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 20 | b'\n\x1a\x64ispatch/sdk/v1/exit.proto\x12\x0f\x64ispatch.sdk.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1a\x64ispatch/sdk/v1/call.proto"w\n\x04\x45xit\x12;\n\x06result\x18\x01 \x01(\x0b\x32\x1b.dispatch.sdk.v1.CallResultB\x06\xbaH\x03\xc8\x01\x01R\x06result\x12\x32\n\ttail_call\x18\x02 \x01(\x0b\x32\x15.dispatch.sdk.v1.CallR\x08tailCallB~\n\x13\x63om.dispatch.sdk.v1B\tExitProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3' 21 | ) 22 | 23 | _globals = globals() 24 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 25 | _builder.BuildTopDescriptorsAndMessages( 26 | DESCRIPTOR, "dispatch.sdk.v1.exit_pb2", _globals 27 | ) 28 | if _descriptor._USE_C_DESCRIPTORS == False: 29 | _globals["DESCRIPTOR"]._options = None 30 | _globals["DESCRIPTOR"]._serialized_options = ( 31 | b"\n\023com.dispatch.sdk.v1B\tExitProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 32 | ) 33 | _globals["_EXIT"].fields_by_name["result"]._options = None 34 | _globals["_EXIT"].fields_by_name[ 35 | "result" 36 | ]._serialized_options = b"\272H\003\310\001\001" 37 | _globals["_EXIT"]._serialized_start = 104 38 | _globals["_EXIT"]._serialized_end = 223 39 | # @@protoc_insertion_point(module_scope) 40 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/exit_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Mapping as _Mapping 3 | from typing import Optional as _Optional 4 | from typing import Union as _Union 5 | 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import message as _message 8 | 9 | from buf.validate import validate_pb2 as _validate_pb2 10 | from dispatch.sdk.v1 import call_pb2 as _call_pb2 11 | 12 | DESCRIPTOR: _descriptor.FileDescriptor 13 | 14 | class Exit(_message.Message): 15 | __slots__ = ("result", "tail_call") 16 | RESULT_FIELD_NUMBER: _ClassVar[int] 17 | TAIL_CALL_FIELD_NUMBER: _ClassVar[int] 18 | result: _call_pb2.CallResult 19 | tail_call: _call_pb2.Call 20 | def __init__( 21 | self, 22 | result: _Optional[_Union[_call_pb2.CallResult, _Mapping]] = ..., 23 | tail_call: _Optional[_Union[_call_pb2.Call, _Mapping]] = ..., 24 | ) -> None: ... 25 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/function_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/function.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 17 | from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 18 | 19 | from dispatch.sdk.v1 import exit_pb2 as dispatch_dot_sdk_dot_v1_dot_exit__pb2 20 | from dispatch.sdk.v1 import poll_pb2 as dispatch_dot_sdk_dot_v1_dot_poll__pb2 21 | from dispatch.sdk.v1 import status_pb2 as dispatch_dot_sdk_dot_v1_dot_status__pb2 22 | 23 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 24 | b'\n\x1e\x64ispatch/sdk/v1/function.proto\x12\x0f\x64ispatch.sdk.v1\x1a\x1a\x64ispatch/sdk/v1/exit.proto\x1a\x1a\x64ispatch/sdk/v1/poll.proto\x1a\x1c\x64ispatch/sdk/v1/status.proto\x1a\x19google/protobuf/any.proto\x1a\x1fgoogle/protobuf/timestamp.proto"\xa2\x03\n\nRunRequest\x12\x1a\n\x08\x66unction\x18\x01 \x01(\tR\x08\x66unction\x12,\n\x05input\x18\x02 \x01(\x0b\x32\x14.google.protobuf.AnyH\x00R\x05input\x12>\n\x0bpoll_result\x18\x03 \x01(\x0b\x32\x1b.dispatch.sdk.v1.PollResultH\x00R\npollResult\x12\x1f\n\x0b\x64ispatch_id\x18\x04 \x01(\tR\ndispatchId\x12,\n\x12parent_dispatch_id\x18\x05 \x01(\tR\x10parentDispatchId\x12(\n\x10root_dispatch_id\x18\x06 \x01(\tR\x0erootDispatchId\x12?\n\rcreation_time\x18\x07 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0c\x63reationTime\x12\x43\n\x0f\x65xpiration_time\x18\x08 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\x0e\x65xpirationTimeB\x0b\n\tdirective"\xa5\x01\n\x0bRunResponse\x12/\n\x06status\x18\x01 \x01(\x0e\x32\x17.dispatch.sdk.v1.StatusR\x06status\x12+\n\x04\x65xit\x18\x02 \x01(\x0b\x32\x15.dispatch.sdk.v1.ExitH\x00R\x04\x65xit\x12+\n\x04poll\x18\x03 \x01(\x0b\x32\x15.dispatch.sdk.v1.PollH\x00R\x04pollB\x0b\n\tdirective2U\n\x0f\x46unctionService\x12\x42\n\x03Run\x12\x1b.dispatch.sdk.v1.RunRequest\x1a\x1c.dispatch.sdk.v1.RunResponse"\x00\x42\x82\x01\n\x13\x63om.dispatch.sdk.v1B\rFunctionProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3' 25 | ) 26 | 27 | _globals = globals() 28 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 29 | _builder.BuildTopDescriptorsAndMessages( 30 | DESCRIPTOR, "dispatch.sdk.v1.function_pb2", _globals 31 | ) 32 | if _descriptor._USE_C_DESCRIPTORS == False: 33 | _globals["DESCRIPTOR"]._options = None 34 | _globals["DESCRIPTOR"]._serialized_options = ( 35 | b"\n\023com.dispatch.sdk.v1B\rFunctionProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 36 | ) 37 | _globals["_RUNREQUEST"]._serialized_start = 198 38 | _globals["_RUNREQUEST"]._serialized_end = 616 39 | _globals["_RUNRESPONSE"]._serialized_start = 619 40 | _globals["_RUNRESPONSE"]._serialized_end = 784 41 | _globals["_FUNCTIONSERVICE"]._serialized_start = 786 42 | _globals["_FUNCTIONSERVICE"]._serialized_end = 871 43 | # @@protoc_insertion_point(module_scope) 44 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/function_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Mapping as _Mapping 3 | from typing import Optional as _Optional 4 | from typing import Union as _Union 5 | 6 | from google.protobuf import any_pb2 as _any_pb2 7 | from google.protobuf import descriptor as _descriptor 8 | from google.protobuf import message as _message 9 | from google.protobuf import timestamp_pb2 as _timestamp_pb2 10 | 11 | from dispatch.sdk.v1 import exit_pb2 as _exit_pb2 12 | from dispatch.sdk.v1 import poll_pb2 as _poll_pb2 13 | from dispatch.sdk.v1 import status_pb2 as _status_pb2 14 | 15 | DESCRIPTOR: _descriptor.FileDescriptor 16 | 17 | class RunRequest(_message.Message): 18 | __slots__ = ( 19 | "function", 20 | "input", 21 | "poll_result", 22 | "dispatch_id", 23 | "parent_dispatch_id", 24 | "root_dispatch_id", 25 | "creation_time", 26 | "expiration_time", 27 | ) 28 | FUNCTION_FIELD_NUMBER: _ClassVar[int] 29 | INPUT_FIELD_NUMBER: _ClassVar[int] 30 | POLL_RESULT_FIELD_NUMBER: _ClassVar[int] 31 | DISPATCH_ID_FIELD_NUMBER: _ClassVar[int] 32 | PARENT_DISPATCH_ID_FIELD_NUMBER: _ClassVar[int] 33 | ROOT_DISPATCH_ID_FIELD_NUMBER: _ClassVar[int] 34 | CREATION_TIME_FIELD_NUMBER: _ClassVar[int] 35 | EXPIRATION_TIME_FIELD_NUMBER: _ClassVar[int] 36 | function: str 37 | input: _any_pb2.Any 38 | poll_result: _poll_pb2.PollResult 39 | dispatch_id: str 40 | parent_dispatch_id: str 41 | root_dispatch_id: str 42 | creation_time: _timestamp_pb2.Timestamp 43 | expiration_time: _timestamp_pb2.Timestamp 44 | def __init__( 45 | self, 46 | function: _Optional[str] = ..., 47 | input: _Optional[_Union[_any_pb2.Any, _Mapping]] = ..., 48 | poll_result: _Optional[_Union[_poll_pb2.PollResult, _Mapping]] = ..., 49 | dispatch_id: _Optional[str] = ..., 50 | parent_dispatch_id: _Optional[str] = ..., 51 | root_dispatch_id: _Optional[str] = ..., 52 | creation_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., 53 | expiration_time: _Optional[_Union[_timestamp_pb2.Timestamp, _Mapping]] = ..., 54 | ) -> None: ... 55 | 56 | class RunResponse(_message.Message): 57 | __slots__ = ("status", "exit", "poll") 58 | STATUS_FIELD_NUMBER: _ClassVar[int] 59 | EXIT_FIELD_NUMBER: _ClassVar[int] 60 | POLL_FIELD_NUMBER: _ClassVar[int] 61 | status: _status_pb2.Status 62 | exit: _exit_pb2.Exit 63 | poll: _poll_pb2.Poll 64 | def __init__( 65 | self, 66 | status: _Optional[_Union[_status_pb2.Status, str]] = ..., 67 | exit: _Optional[_Union[_exit_pb2.Exit, _Mapping]] = ..., 68 | poll: _Optional[_Union[_poll_pb2.Poll, _Mapping]] = ..., 69 | ) -> None: ... 70 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/poll_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/poll.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | from google.protobuf import any_pb2 as google_dot_protobuf_dot_any__pb2 17 | from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 18 | 19 | from buf.validate import validate_pb2 as buf_dot_validate_dot_validate__pb2 20 | from dispatch.sdk.v1 import call_pb2 as dispatch_dot_sdk_dot_v1_dot_call__pb2 21 | from dispatch.sdk.v1 import error_pb2 as dispatch_dot_sdk_dot_v1_dot_error__pb2 22 | 23 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 24 | b'\n\x1a\x64ispatch/sdk/v1/poll.proto\x12\x0f\x64ispatch.sdk.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1a\x64ispatch/sdk/v1/call.proto\x1a\x1b\x64ispatch/sdk/v1/error.proto\x1a\x19google/protobuf/any.proto\x1a\x1egoogle/protobuf/duration.proto"\xd8\x02\n\x04Poll\x12)\n\x0f\x63oroutine_state\x18\x01 \x01(\x0cH\x00R\x0e\x63oroutineState\x12J\n\x15typed_coroutine_state\x18\x06 \x01(\x0b\x32\x14.google.protobuf.AnyH\x00R\x13typedCoroutineState\x12+\n\x05\x63\x61lls\x18\x02 \x03(\x0b\x32\x15.dispatch.sdk.v1.CallR\x05\x63\x61lls\x12\x43\n\x08max_wait\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\r\xbaH\n\xaa\x01\x04\x32\x02\x08\x01\xc8\x01\x01R\x07maxWait\x12.\n\x0bmax_results\x18\x04 \x01(\x05\x42\r\xbaH\n\x1a\x05\x18\xe8\x07(\x01\xc8\x01\x01R\nmaxResults\x12.\n\x0bmin_results\x18\x05 \x01(\x05\x42\r\xbaH\n\x1a\x05\x18\xe8\x07(\x01\xc8\x01\x01R\nminResultsB\x07\n\x05state"\xf1\x01\n\nPollResult\x12)\n\x0f\x63oroutine_state\x18\x01 \x01(\x0cH\x00R\x0e\x63oroutineState\x12J\n\x15typed_coroutine_state\x18\x04 \x01(\x0b\x32\x14.google.protobuf.AnyH\x00R\x13typedCoroutineState\x12\x35\n\x07results\x18\x02 \x03(\x0b\x32\x1b.dispatch.sdk.v1.CallResultR\x07results\x12,\n\x05\x65rror\x18\x03 \x01(\x0b\x32\x16.dispatch.sdk.v1.ErrorR\x05\x65rrorB\x07\n\x05stateB~\n\x13\x63om.dispatch.sdk.v1B\tPollProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3' 25 | ) 26 | 27 | _globals = globals() 28 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 29 | _builder.BuildTopDescriptorsAndMessages( 30 | DESCRIPTOR, "dispatch.sdk.v1.poll_pb2", _globals 31 | ) 32 | if _descriptor._USE_C_DESCRIPTORS == False: 33 | _globals["DESCRIPTOR"]._options = None 34 | _globals["DESCRIPTOR"]._serialized_options = ( 35 | b"\n\023com.dispatch.sdk.v1B\tPollProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 36 | ) 37 | _globals["_POLL"].fields_by_name["max_wait"]._options = None 38 | _globals["_POLL"].fields_by_name[ 39 | "max_wait" 40 | ]._serialized_options = b"\272H\n\252\001\0042\002\010\001\310\001\001" 41 | _globals["_POLL"].fields_by_name["max_results"]._options = None 42 | _globals["_POLL"].fields_by_name[ 43 | "max_results" 44 | ]._serialized_options = b"\272H\n\032\005\030\350\007(\001\310\001\001" 45 | _globals["_POLL"].fields_by_name["min_results"]._options = None 46 | _globals["_POLL"].fields_by_name[ 47 | "min_results" 48 | ]._serialized_options = b"\272H\n\032\005\030\350\007(\001\310\001\001" 49 | _globals["_POLL"]._serialized_start = 193 50 | _globals["_POLL"]._serialized_end = 537 51 | _globals["_POLLRESULT"]._serialized_start = 540 52 | _globals["_POLLRESULT"]._serialized_end = 781 53 | # @@protoc_insertion_point(module_scope) 54 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/poll_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | from typing import Iterable as _Iterable 3 | from typing import Mapping as _Mapping 4 | from typing import Optional as _Optional 5 | from typing import Union as _Union 6 | 7 | from google.protobuf import any_pb2 as _any_pb2 8 | from google.protobuf import descriptor as _descriptor 9 | from google.protobuf import duration_pb2 as _duration_pb2 10 | from google.protobuf import message as _message 11 | from google.protobuf.internal import containers as _containers 12 | 13 | from buf.validate import validate_pb2 as _validate_pb2 14 | from dispatch.sdk.v1 import call_pb2 as _call_pb2 15 | from dispatch.sdk.v1 import error_pb2 as _error_pb2 16 | 17 | DESCRIPTOR: _descriptor.FileDescriptor 18 | 19 | class Poll(_message.Message): 20 | __slots__ = ( 21 | "coroutine_state", 22 | "typed_coroutine_state", 23 | "calls", 24 | "max_wait", 25 | "max_results", 26 | "min_results", 27 | ) 28 | COROUTINE_STATE_FIELD_NUMBER: _ClassVar[int] 29 | TYPED_COROUTINE_STATE_FIELD_NUMBER: _ClassVar[int] 30 | CALLS_FIELD_NUMBER: _ClassVar[int] 31 | MAX_WAIT_FIELD_NUMBER: _ClassVar[int] 32 | MAX_RESULTS_FIELD_NUMBER: _ClassVar[int] 33 | MIN_RESULTS_FIELD_NUMBER: _ClassVar[int] 34 | coroutine_state: bytes 35 | typed_coroutine_state: _any_pb2.Any 36 | calls: _containers.RepeatedCompositeFieldContainer[_call_pb2.Call] 37 | max_wait: _duration_pb2.Duration 38 | max_results: int 39 | min_results: int 40 | def __init__( 41 | self, 42 | coroutine_state: _Optional[bytes] = ..., 43 | typed_coroutine_state: _Optional[_Union[_any_pb2.Any, _Mapping]] = ..., 44 | calls: _Optional[_Iterable[_Union[_call_pb2.Call, _Mapping]]] = ..., 45 | max_wait: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ..., 46 | max_results: _Optional[int] = ..., 47 | min_results: _Optional[int] = ..., 48 | ) -> None: ... 49 | 50 | class PollResult(_message.Message): 51 | __slots__ = ("coroutine_state", "typed_coroutine_state", "results", "error") 52 | COROUTINE_STATE_FIELD_NUMBER: _ClassVar[int] 53 | TYPED_COROUTINE_STATE_FIELD_NUMBER: _ClassVar[int] 54 | RESULTS_FIELD_NUMBER: _ClassVar[int] 55 | ERROR_FIELD_NUMBER: _ClassVar[int] 56 | coroutine_state: bytes 57 | typed_coroutine_state: _any_pb2.Any 58 | results: _containers.RepeatedCompositeFieldContainer[_call_pb2.CallResult] 59 | error: _error_pb2.Error 60 | def __init__( 61 | self, 62 | coroutine_state: _Optional[bytes] = ..., 63 | typed_coroutine_state: _Optional[_Union[_any_pb2.Any, _Mapping]] = ..., 64 | results: _Optional[_Iterable[_Union[_call_pb2.CallResult, _Mapping]]] = ..., 65 | error: _Optional[_Union[_error_pb2.Error, _Mapping]] = ..., 66 | ) -> None: ... 67 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/src/dispatch/sdk/v1/py.typed -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/status_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: dispatch/sdk/v1/status.proto 4 | # Protobuf Python Version: 4.25.2 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | 11 | # @@protoc_insertion_point(imports) 12 | 13 | _sym_db = _symbol_database.Default() 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 17 | b"\n\x1c\x64ispatch/sdk/v1/status.proto\x12\x0f\x64ispatch.sdk.v1*\x93\x03\n\x06Status\x12\x16\n\x12STATUS_UNSPECIFIED\x10\x00\x12\r\n\tSTATUS_OK\x10\x01\x12\x12\n\x0eSTATUS_TIMEOUT\x10\x02\x12\x14\n\x10STATUS_THROTTLED\x10\x03\x12\x1b\n\x17STATUS_INVALID_ARGUMENT\x10\x04\x12\x1b\n\x17STATUS_INVALID_RESPONSE\x10\x05\x12\x1a\n\x16STATUS_TEMPORARY_ERROR\x10\x06\x12\x1a\n\x16STATUS_PERMANENT_ERROR\x10\x07\x12\x1d\n\x19STATUS_INCOMPATIBLE_STATE\x10\x08\x12\x14\n\x10STATUS_DNS_ERROR\x10\t\x12\x14\n\x10STATUS_TCP_ERROR\x10\n\x12\x14\n\x10STATUS_TLS_ERROR\x10\x0b\x12\x15\n\x11STATUS_HTTP_ERROR\x10\x0c\x12\x1a\n\x16STATUS_UNAUTHENTICATED\x10\r\x12\x1c\n\x18STATUS_PERMISSION_DENIED\x10\x0e\x12\x14\n\x10STATUS_NOT_FOUND\x10\x0f\x42\x80\x01\n\x13\x63om.dispatch.sdk.v1B\x0bStatusProtoP\x01\xa2\x02\x03\x44SX\xaa\x02\x0f\x44ispatch.Sdk.V1\xca\x02\x0f\x44ispatch\\Sdk\\V1\xe2\x02\x1b\x44ispatch\\Sdk\\V1\\GPBMetadata\xea\x02\x11\x44ispatch::Sdk::V1b\x06proto3" 18 | ) 19 | 20 | _globals = globals() 21 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 22 | _builder.BuildTopDescriptorsAndMessages( 23 | DESCRIPTOR, "dispatch.sdk.v1.status_pb2", _globals 24 | ) 25 | if _descriptor._USE_C_DESCRIPTORS == False: 26 | _globals["DESCRIPTOR"]._options = None 27 | _globals["DESCRIPTOR"]._serialized_options = ( 28 | b"\n\023com.dispatch.sdk.v1B\013StatusProtoP\001\242\002\003DSX\252\002\017Dispatch.Sdk.V1\312\002\017Dispatch\\Sdk\\V1\342\002\033Dispatch\\Sdk\\V1\\GPBMetadata\352\002\021Dispatch::Sdk::V1" 29 | ) 30 | _globals["_STATUS"]._serialized_start = 50 31 | _globals["_STATUS"]._serialized_end = 453 32 | # @@protoc_insertion_point(module_scope) 33 | -------------------------------------------------------------------------------- /src/dispatch/sdk/v1/status_pb2.pyi: -------------------------------------------------------------------------------- 1 | from typing import ClassVar as _ClassVar 2 | 3 | from google.protobuf import descriptor as _descriptor 4 | from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper 5 | 6 | DESCRIPTOR: _descriptor.FileDescriptor 7 | 8 | class Status(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): 9 | __slots__ = () 10 | STATUS_UNSPECIFIED: _ClassVar[Status] 11 | STATUS_OK: _ClassVar[Status] 12 | STATUS_TIMEOUT: _ClassVar[Status] 13 | STATUS_THROTTLED: _ClassVar[Status] 14 | STATUS_INVALID_ARGUMENT: _ClassVar[Status] 15 | STATUS_INVALID_RESPONSE: _ClassVar[Status] 16 | STATUS_TEMPORARY_ERROR: _ClassVar[Status] 17 | STATUS_PERMANENT_ERROR: _ClassVar[Status] 18 | STATUS_INCOMPATIBLE_STATE: _ClassVar[Status] 19 | STATUS_DNS_ERROR: _ClassVar[Status] 20 | STATUS_TCP_ERROR: _ClassVar[Status] 21 | STATUS_TLS_ERROR: _ClassVar[Status] 22 | STATUS_HTTP_ERROR: _ClassVar[Status] 23 | STATUS_UNAUTHENTICATED: _ClassVar[Status] 24 | STATUS_PERMISSION_DENIED: _ClassVar[Status] 25 | STATUS_NOT_FOUND: _ClassVar[Status] 26 | 27 | STATUS_UNSPECIFIED: Status 28 | STATUS_OK: Status 29 | STATUS_TIMEOUT: Status 30 | STATUS_THROTTLED: Status 31 | STATUS_INVALID_ARGUMENT: Status 32 | STATUS_INVALID_RESPONSE: Status 33 | STATUS_TEMPORARY_ERROR: Status 34 | STATUS_PERMANENT_ERROR: Status 35 | STATUS_INCOMPATIBLE_STATE: Status 36 | STATUS_DNS_ERROR: Status 37 | STATUS_TCP_ERROR: Status 38 | STATUS_TLS_ERROR: Status 39 | STATUS_HTTP_ERROR: Status 40 | STATUS_UNAUTHENTICATED: Status 41 | STATUS_PERMISSION_DENIED: Status 42 | STATUS_NOT_FOUND: Status 43 | -------------------------------------------------------------------------------- /src/dispatch/signature/__init__.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import logging 3 | import os 4 | from datetime import datetime, timedelta 5 | from typing import Optional, Sequence, Set, Union, cast 6 | from urllib.parse import urlparse 7 | 8 | import http_sfv 9 | from cryptography.hazmat.primitives.asymmetric.ed25519 import ( 10 | Ed25519PrivateKey, 11 | Ed25519PublicKey, 12 | ) 13 | from http_message_signatures import ( 14 | HTTPMessageSigner, 15 | HTTPMessageVerifier, 16 | InvalidSignature, 17 | VerifyResult, 18 | ) 19 | from http_message_signatures.algorithms import ED25519 20 | from http_message_signatures.structures import CaseInsensitiveDict 21 | 22 | from .digest import generate_content_digest, verify_content_digest 23 | from .key import ( 24 | KeyResolver, 25 | private_key_from_bytes, 26 | private_key_from_pem, 27 | public_key_from_bytes, 28 | public_key_from_pem, 29 | ) 30 | from .request import Request 31 | 32 | ALGORITHM = ED25519 33 | DEFAULT_KEY_ID = "default" 34 | 35 | COVERED_COMPONENT_IDS = { 36 | "@method", 37 | "@path", 38 | "@authority", 39 | "content-type", 40 | "content-digest", 41 | } 42 | 43 | logger = logging.getLogger(__name__) 44 | 45 | 46 | def sign_request(request: Request, key: Ed25519PrivateKey, created: datetime): 47 | """Sign a request using HTTP Message Signatures. 48 | 49 | The function adds three additional headers: Content-Digest, 50 | Signature-Input, and Signature. See the following spec for more details: 51 | https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures 52 | 53 | The signature covers the request method, the URL host and path, the 54 | Content-Type header, and the request body. At this time, an ED25519 55 | signature is generated with a hard-coded key ID of "default". 56 | 57 | Args: 58 | request: The request to sign. 59 | key: The Ed25519 private key to use to generate the signature. 60 | created: The times at which the signature is created. 61 | """ 62 | logger.debug("signing request with %d byte body", len(request.body)) 63 | request.headers["Content-Digest"] = generate_content_digest(request.body) 64 | 65 | signer = HTTPMessageSigner( 66 | signature_algorithm=ALGORITHM, 67 | key_resolver=KeyResolver(key_id=DEFAULT_KEY_ID, private_key=key), 68 | ) 69 | signer.sign( 70 | request, 71 | key_id=DEFAULT_KEY_ID, 72 | covered_component_ids=cast(Sequence[str], COVERED_COMPONENT_IDS), 73 | created=created, 74 | label="dispatch", 75 | include_alg=True, 76 | ) 77 | logger.debug("signed request successfully") 78 | 79 | 80 | def verify_request(request: Request, key: Ed25519PublicKey, max_age: timedelta): 81 | """Verify a request containing an HTTP Message Signature. 82 | 83 | The function checks three additional headers: Content-Digest, 84 | Signature-Input, and Signature. See the following spec for more details: 85 | https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-message-signatures 86 | 87 | The function checks signatures that cover at least the request method, the 88 | URL host and path, the Content-Type header, and the request body (via the 89 | Content-Digest header). At this time, signatures must use a hard-coded key 90 | ID of "default". 91 | 92 | Args: 93 | request: The request to verify. 94 | key: The Ed25519 public key to use to verify the signature. 95 | max_age: The maximum age of the signature. 96 | """ 97 | logger.debug("verifying request signature") 98 | 99 | # Verify embedded signatures. 100 | key_resolver = KeyResolver(key_id=DEFAULT_KEY_ID, public_key=key) 101 | verifier = HTTPMessageVerifier( 102 | signature_algorithm=ALGORITHM, key_resolver=key_resolver 103 | ) 104 | results = verifier.verify(request, max_age=max_age) 105 | 106 | # Check that at least one signature covers the required components. 107 | for result in results: 108 | covered_components = extract_covered_components(result) 109 | if covered_components.issuperset(COVERED_COMPONENT_IDS): 110 | break 111 | else: 112 | raise ValueError( 113 | f"no signatures found that covered all required components ({COVERED_COMPONENT_IDS})" 114 | ) 115 | 116 | # Check that the Content-Digest header matches the body. 117 | verify_content_digest(request.headers["Content-Digest"], request.body) 118 | 119 | 120 | def extract_covered_components(result: VerifyResult) -> Set[str]: 121 | covered_components: Set[str] = set() 122 | for key in result.covered_components.keys(): 123 | item = http_sfv.Item() 124 | item.parse(key.encode()) 125 | assert isinstance(item.value, str) 126 | covered_components.add(item.value) 127 | 128 | return covered_components 129 | 130 | 131 | def parse_verification_key( 132 | verification_key: Optional[Union[Ed25519PublicKey, str, bytes]], 133 | endpoint: Optional[str] = None, 134 | ) -> Optional[Ed25519PublicKey]: 135 | # This function depends a lot on global context like enviornment variables 136 | # and logging configuration. It's not ideal for testing, but it's useful to 137 | # unify the behavior of the Dispatch class everywhere the signature module 138 | # is used. 139 | if isinstance(verification_key, Ed25519PublicKey): 140 | return verification_key 141 | 142 | # Keep track of whether the key was obtained from the environment, so that 143 | # we can tweak the error messages accordingly. 144 | from_env = False 145 | if not verification_key: 146 | try: 147 | verification_key = os.environ["DISPATCH_VERIFICATION_KEY"] 148 | except KeyError: 149 | return None 150 | from_env = verification_key is not None 151 | 152 | if isinstance(verification_key, bytes): 153 | verification_key = verification_key.decode() 154 | 155 | # Be forgiving when accepting keys in PEM format, which may span 156 | # multiple lines. Users attempting to pass a PEM key via an environment 157 | # variable may accidentally include literal "\n" bytes rather than a 158 | # newline char (0xA). 159 | public_key: Optional[Ed25519PublicKey] = None 160 | try: 161 | public_key = public_key_from_pem(verification_key.replace("\\n", "\n")) 162 | except ValueError: 163 | pass 164 | 165 | # If the key is not in PEM format, try to decode it as base64 string. 166 | if not public_key: 167 | try: 168 | public_key = public_key_from_bytes( 169 | base64.b64decode(verification_key.encode()) 170 | ) 171 | except ValueError: 172 | if from_env: 173 | raise ValueError( 174 | f"invalid DISPATCH_VERIFICATION_KEY '{verification_key}'" 175 | ) 176 | raise ValueError(f"invalid verification key '{verification_key}'") 177 | 178 | # Print diagostic information about the key, this is useful for debugging. 179 | url_scheme = "" 180 | if endpoint: 181 | try: 182 | parsed_url = urlparse(endpoint) 183 | url_scheme = parsed_url.scheme 184 | except: 185 | pass 186 | 187 | if public_key: 188 | base64_key = base64.b64encode(public_key.public_bytes_raw()).decode() 189 | logger.info("verifying request signatures using key %s", base64_key) 190 | elif url_scheme != "bridge": 191 | logger.warning( 192 | "request verification is disabled because DISPATCH_VERIFICATION_KEY is not set" 193 | ) 194 | return public_key 195 | -------------------------------------------------------------------------------- /src/dispatch/signature/digest.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | from typing import Union 4 | 5 | import http_sfv 6 | from http_message_signatures import InvalidSignature 7 | 8 | 9 | def generate_content_digest(body: Union[str, bytes]) -> str: 10 | """Returns a SHA-512 Content-Digest header, according to 11 | https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-digest-headers-13 12 | """ 13 | if isinstance(body, str): 14 | body = body.encode() 15 | 16 | digest = hashlib.sha512(body).digest() 17 | return str(http_sfv.Dictionary({"sha-512": digest})) 18 | 19 | 20 | def verify_content_digest(digest_header: Union[str, bytes], body: Union[str, bytes]): 21 | """Verify a SHA-256 or SHA-512 Content-Digest header matches a 22 | request body.""" 23 | if isinstance(body, str): 24 | body = body.encode() 25 | if isinstance(digest_header, str): 26 | digest_header = digest_header.encode() 27 | 28 | parsed_header = http_sfv.Dictionary() 29 | parsed_header.parse(digest_header) 30 | 31 | # See https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-digest-headers-13#establish-hash-algorithm-registry 32 | if "sha-512" in parsed_header: 33 | digest = parsed_header["sha-512"].value 34 | expect_digest = hashlib.sha512(body).digest() 35 | elif "sha-256" in parsed_header: 36 | digest = parsed_header["sha-256"].value 37 | expect_digest = hashlib.sha256(body).digest() 38 | else: 39 | raise ValueError("missing content digest in http request header") 40 | 41 | if not hmac.compare_digest(digest, expect_digest): 42 | raise InvalidSignature( 43 | "digest of the request body does not match the Content-Digest header" 44 | ) 45 | -------------------------------------------------------------------------------- /src/dispatch/signature/key.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional, Union 3 | 4 | from cryptography.hazmat.primitives.asymmetric.ed25519 import ( 5 | Ed25519PrivateKey, 6 | Ed25519PublicKey, 7 | ) 8 | from cryptography.hazmat.primitives.serialization import ( 9 | load_pem_private_key, 10 | load_pem_public_key, 11 | ) 12 | from http_message_signatures import HTTPSignatureKeyResolver 13 | 14 | 15 | def public_key_from_pem(pem: Union[str, bytes]) -> Ed25519PublicKey: 16 | """Returns an Ed25519 public key given a PEM representation.""" 17 | if isinstance(pem, str): 18 | pem = pem.encode() 19 | 20 | key = load_pem_public_key(pem) 21 | if not isinstance(key, Ed25519PublicKey): 22 | raise ValueError(f"unexpected public key type: {type(key)}") 23 | return key 24 | 25 | 26 | def public_key_from_bytes(key: bytes) -> Ed25519PublicKey: 27 | """Returns an Ed25519 public key from 32 raw bytes.""" 28 | return Ed25519PublicKey.from_public_bytes(key) 29 | 30 | 31 | def private_key_from_pem( 32 | pem: Union[str, bytes], password: Optional[bytes] = None 33 | ) -> Ed25519PrivateKey: 34 | """Returns an Ed25519 private key given a PEM representation 35 | and optional password.""" 36 | if isinstance(pem, str): 37 | pem = pem.encode() 38 | if isinstance(password, str): 39 | password = password.encode() 40 | 41 | key = load_pem_private_key(pem, password=password) 42 | if not isinstance(key, Ed25519PrivateKey): 43 | raise ValueError(f"unexpected private key type: {type(key)}") 44 | return key 45 | 46 | 47 | def private_key_from_bytes(key: bytes) -> Ed25519PrivateKey: 48 | """Returns an Ed25519 private key from 32 raw bytes.""" 49 | return Ed25519PrivateKey.from_private_bytes(key) 50 | 51 | 52 | @dataclass 53 | class KeyResolver(HTTPSignatureKeyResolver): 54 | """KeyResolver provides public and private keys. 55 | 56 | At this time, multiple keys and/or key types are not supported. 57 | Keys must be Ed25519 keys and have an ID of DEFAULT_KEY_ID. 58 | """ 59 | 60 | key_id: str 61 | public_key: Optional[Ed25519PublicKey] = None 62 | private_key: Optional[Ed25519PrivateKey] = None 63 | 64 | def resolve_public_key(self, key_id: str): 65 | if key_id != self.key_id or self.public_key is None: 66 | raise ValueError(f"public key '{key_id}' not available") 67 | 68 | return self.public_key 69 | 70 | def resolve_private_key(self, key_id: str): 71 | if key_id != self.key_id or self.private_key is None: 72 | raise ValueError(f"private key '{key_id}' not available") 73 | 74 | return self.private_key 75 | -------------------------------------------------------------------------------- /src/dispatch/signature/request.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Union 3 | 4 | from http_message_signatures.structures import CaseInsensitiveDict 5 | 6 | 7 | @dataclass 8 | class Request: 9 | """A framework-agnostic representation of an HTTP request.""" 10 | 11 | method: str 12 | url: str 13 | headers: CaseInsensitiveDict 14 | body: Union[str, bytes] 15 | -------------------------------------------------------------------------------- /src/dispatch/status.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import ssl 3 | from typing import Any, Callable, Dict, Type, Union 4 | 5 | from dispatch.sdk.v1 import status_pb2 as status_pb 6 | 7 | 8 | @enum.unique 9 | class Status(int, enum.Enum): 10 | """Enumeration of the possible values that can be used in the return status 11 | of functions. 12 | """ 13 | 14 | UNSPECIFIED = status_pb.STATUS_UNSPECIFIED 15 | OK = status_pb.STATUS_OK 16 | TIMEOUT = status_pb.STATUS_TIMEOUT 17 | THROTTLED = status_pb.STATUS_THROTTLED 18 | INVALID_ARGUMENT = status_pb.STATUS_INVALID_ARGUMENT 19 | INVALID_RESPONSE = status_pb.STATUS_INVALID_RESPONSE 20 | TEMPORARY_ERROR = status_pb.STATUS_TEMPORARY_ERROR 21 | PERMANENT_ERROR = status_pb.STATUS_PERMANENT_ERROR 22 | INCOMPATIBLE_STATE = status_pb.STATUS_INCOMPATIBLE_STATE 23 | DNS_ERROR = status_pb.STATUS_DNS_ERROR 24 | TCP_ERROR = status_pb.STATUS_TCP_ERROR 25 | TLS_ERROR = status_pb.STATUS_TLS_ERROR 26 | HTTP_ERROR = status_pb.STATUS_HTTP_ERROR 27 | UNAUTHENTICATED = status_pb.STATUS_UNAUTHENTICATED 28 | PERMISSION_DENIED = status_pb.STATUS_PERMISSION_DENIED 29 | NOT_FOUND = status_pb.STATUS_NOT_FOUND 30 | 31 | _proto: status_pb.Status 32 | 33 | def __repr__(self): 34 | return self.name 35 | 36 | def __str__(self): 37 | return self.name 38 | 39 | # TODO: remove, this is only used for the emulated wait of call results 40 | @property 41 | def temporary(self) -> bool: 42 | return self in { 43 | Status.TIMEOUT, 44 | Status.THROTTLED, 45 | Status.TEMPORARY_ERROR, 46 | Status.INCOMPATIBLE_STATE, 47 | Status.DNS_ERROR, 48 | Status.TCP_ERROR, 49 | Status.TLS_ERROR, 50 | Status.HTTP_ERROR, 51 | } 52 | 53 | 54 | # Maybe we should find a better way to define that enum. It's that way to please 55 | # Mypy and provide documentation for the enum values. 56 | 57 | Status.UNSPECIFIED.__doc__ = "Status not specified (default)" 58 | Status.UNSPECIFIED._proto = status_pb.STATUS_UNSPECIFIED 59 | Status.OK.__doc__ = "Coroutine returned as expected" 60 | Status.OK._proto = status_pb.STATUS_OK 61 | Status.TIMEOUT.__doc__ = "Coroutine encountered a timeout and may be retried" 62 | Status.TIMEOUT._proto = status_pb.STATUS_TIMEOUT 63 | Status.THROTTLED.__doc__ = "Coroutine was throttled and may be retried later" 64 | Status.THROTTLED._proto = status_pb.STATUS_THROTTLED 65 | Status.INVALID_ARGUMENT.__doc__ = "Coroutine was provided an invalid type of input" 66 | Status.INVALID_ARGUMENT._proto = status_pb.STATUS_INVALID_ARGUMENT 67 | Status.INVALID_RESPONSE.__doc__ = "Coroutine was provided an unexpected response" 68 | Status.INVALID_RESPONSE._proto = status_pb.STATUS_INVALID_RESPONSE 69 | Status.TEMPORARY_ERROR.__doc__ = ( 70 | "Coroutine encountered a temporary error, may be retried" 71 | ) 72 | Status.TEMPORARY_ERROR._proto = status_pb.STATUS_TEMPORARY_ERROR 73 | Status.PERMANENT_ERROR.__doc__ = ( 74 | "Coroutine encountered a permanent error, should not be retried" 75 | ) 76 | Status.PERMANENT_ERROR._proto = status_pb.STATUS_PERMANENT_ERROR 77 | Status.INCOMPATIBLE_STATE.__doc__ = ( 78 | "Coroutine was provided an incompatible state. May be restarted from scratch" 79 | ) 80 | Status.INCOMPATIBLE_STATE._proto = status_pb.STATUS_INCOMPATIBLE_STATE 81 | Status.DNS_ERROR.__doc__ = "Coroutine encountered a DNS error" 82 | Status.DNS_ERROR._proto = status_pb.STATUS_DNS_ERROR 83 | Status.TCP_ERROR.__doc__ = "Coroutine encountered a TCP error" 84 | Status.TCP_ERROR._proto = status_pb.STATUS_TCP_ERROR 85 | Status.TLS_ERROR.__doc__ = "Coroutine encountered a TLS error" 86 | Status.TLS_ERROR._proto = status_pb.STATUS_TLS_ERROR 87 | Status.HTTP_ERROR.__doc__ = "Coroutine encountered an HTTP error" 88 | Status.HTTP_ERROR._proto = status_pb.STATUS_HTTP_ERROR 89 | Status.UNAUTHENTICATED.__doc__ = "An operation was performed without authentication" 90 | Status.UNAUTHENTICATED._proto = status_pb.STATUS_UNAUTHENTICATED 91 | Status.PERMISSION_DENIED.__doc__ = "An operation was performed without permission" 92 | Status.PERMISSION_DENIED._proto = status_pb.STATUS_PERMISSION_DENIED 93 | Status.NOT_FOUND.__doc__ = "An operation was performed on a non-existent resource" 94 | Status.NOT_FOUND._proto = status_pb.STATUS_NOT_FOUND 95 | 96 | _ERROR_TYPES: Dict[Type[Exception], Union[Status, Callable[[Exception], Status]]] = {} 97 | _OUTPUT_TYPES: Dict[Type[Any], Union[Status, Callable[[Any], Status]]] = {} 98 | 99 | 100 | def status_for_error(error: BaseException) -> Status: 101 | """Returns a Status that corresponds to the specified error.""" 102 | # See if the error matches one of the registered types. 103 | status_or_handler = _find_status_or_handler(error, _ERROR_TYPES) 104 | if status_or_handler is not None: 105 | if isinstance(status_or_handler, Status): 106 | return status_or_handler 107 | return status_or_handler(error) 108 | # If not, resort to standard error categorization. 109 | # 110 | # See https://docs.python.org/3/library/exceptions.html 111 | if isinstance(error, TimeoutError): 112 | return Status.TIMEOUT 113 | elif isinstance(error, TypeError) or isinstance(error, ValueError): 114 | return Status.INVALID_ARGUMENT 115 | elif isinstance(error, ConnectionError): 116 | return Status.TCP_ERROR 117 | elif isinstance(error, PermissionError): 118 | return Status.PERMISSION_DENIED 119 | elif isinstance(error, FileNotFoundError): 120 | return Status.NOT_FOUND 121 | elif ( 122 | isinstance(error, EOFError) 123 | or isinstance(error, InterruptedError) 124 | or isinstance(error, KeyboardInterrupt) 125 | or isinstance(error, OSError) 126 | ): 127 | # For OSError, we might want to categorize the values of errnon 128 | # to determine whether the error is temporary or permanent. 129 | # 130 | # In general, permanent errors from the OS are rare because they 131 | # tend to be caused by invalid use of syscalls, which are 132 | # unlikely at higher abstraction levels. 133 | return Status.TEMPORARY_ERROR 134 | elif isinstance(error, ssl.SSLError) or isinstance(error, ssl.CertificateError): 135 | return Status.TLS_ERROR 136 | return Status.PERMANENT_ERROR 137 | 138 | 139 | def status_for_output(output: Any) -> Status: 140 | """Returns a Status that corresponds to the specified output value.""" 141 | # See if the output value matches one of the registered types. 142 | status_or_handler = _find_status_or_handler(output, _OUTPUT_TYPES) 143 | if status_or_handler is not None: 144 | if isinstance(status_or_handler, Status): 145 | return status_or_handler 146 | return status_or_handler(output) 147 | 148 | return Status.OK 149 | 150 | 151 | def register_error_type( 152 | error_type: Type[Exception], 153 | status_or_handler: Union[Status, Callable[[Exception], Status]], 154 | ): 155 | """Register an error type to Status mapping. 156 | 157 | The caller can either register a base exception and a handler, which 158 | derives a Status from errors of this type. Or, if there's only one 159 | exception to Status mapping to register, the caller can simply pass 160 | the exception class and the associated Status. 161 | """ 162 | _ERROR_TYPES[error_type] = status_or_handler 163 | 164 | 165 | def register_output_type( 166 | output_type: Type[Any], status_or_handler: Union[Status, Callable[[Any], Status]] 167 | ): 168 | """Register an output type to Status mapping. 169 | 170 | The caller can either register a base class and a handler, which 171 | derives a Status from other classes of this type. Or, if there's 172 | only one output class to Status mapping to register, the caller can 173 | simply pass the class and the associated Status. 174 | """ 175 | _OUTPUT_TYPES[output_type] = status_or_handler 176 | 177 | 178 | def _find_status_or_handler(obj, types): 179 | for cls in type(obj).__mro__: 180 | try: 181 | return types[cls] 182 | except KeyError: 183 | pass 184 | 185 | return None # not found 186 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/tests/__init__.py -------------------------------------------------------------------------------- /tests/dispatch/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/tests/dispatch/__init__.py -------------------------------------------------------------------------------- /tests/dispatch/experimental/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/tests/dispatch/experimental/__init__.py -------------------------------------------------------------------------------- /tests/dispatch/experimental/durable/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/tests/dispatch/experimental/durable/__init__.py -------------------------------------------------------------------------------- /tests/dispatch/experimental/durable/test_coroutine.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import unittest 3 | import warnings 4 | from types import CodeType, FrameType, coroutine 5 | 6 | from dispatch.experimental.durable import durable 7 | 8 | 9 | @coroutine 10 | @durable 11 | def yields(n): 12 | return (yield n) 13 | 14 | 15 | @durable 16 | async def durable_coroutine(a): 17 | await yields(a) 18 | a += 1 19 | await yields(a) 20 | a += 1 21 | await yields(a) 22 | 23 | 24 | @durable 25 | async def nested_coroutines(start): 26 | await durable_coroutine(start) 27 | await durable_coroutine(start + 3) 28 | 29 | 30 | class TestCoroutine(unittest.TestCase): 31 | def test_pickle(self): 32 | # Create an instance and run it to the first yield point. 33 | c = durable_coroutine(1) 34 | g = c.__await__() 35 | assert next(g) == 1 36 | 37 | # Copy the coroutine by serializing the DurableCoroutine instance to bytes 38 | # and back. 39 | state = pickle.dumps(c) 40 | c2 = pickle.loads(state) 41 | g2 = c2.__await__() 42 | 43 | # The copy should start from where the previous coroutine was suspended. 44 | assert next(g2) == 2 45 | assert next(g2) == 3 46 | 47 | # The original coroutine is not affected. 48 | assert next(g) == 2 49 | assert next(g) == 3 50 | 51 | def test_nested(self): 52 | expect = [1, 2, 3, 4, 5, 6] 53 | c = nested_coroutines(1) 54 | g = c.__await__() 55 | assert list(g) == expect 56 | 57 | # Check that the coroutine can be pickled at every yield point. 58 | for i in range(len(expect)): 59 | # Create a coroutine and advance to the i'th yield point. 60 | c = nested_coroutines(1) 61 | g = c.__await__() 62 | for j in range(i): 63 | assert next(g) == expect[j] 64 | 65 | # Create a copy of the coroutine. 66 | state = pickle.dumps(c) 67 | c2 = pickle.loads(state) 68 | g2 = c2.__await__() 69 | 70 | # Check that both the original and the copy yield the 71 | # remaining expected values. 72 | for j in range(i, len(expect)): 73 | assert next(g) == expect[j] 74 | assert next(g2) == expect[j] 75 | 76 | def test_coroutine_wrapper(self): 77 | c = durable_coroutine(1) 78 | g = c.__await__() 79 | assert next(g) == 1 80 | 81 | state = pickle.dumps((c, g)) 82 | c2, g2 = pickle.loads(state) 83 | 84 | assert next(g2) == 2 85 | g3 = c2.__await__() 86 | assert next(g3) == 3 87 | 88 | def test_export_cr_fields(self): 89 | c = nested_coroutines(1) 90 | underlying = c.coroutine 91 | 92 | self.assertIsInstance(c.cr_frame, FrameType) 93 | self.assertIs(c.cr_frame, underlying.cr_frame) 94 | 95 | self.assertIsInstance(c.cr_code, CodeType) 96 | self.assertIs(c.cr_code, underlying.cr_code) 97 | 98 | def check(): 99 | self.assertEqual(c.cr_running, underlying.cr_running) 100 | try: 101 | self.assertEqual(c.cr_suspended, underlying.cr_suspended) 102 | except AttributeError: 103 | pass 104 | self.assertEqual(c.cr_origin, underlying.cr_origin) 105 | self.assertIs(c.cr_await, underlying.cr_await) 106 | 107 | check() 108 | for _ in c.__await__(): 109 | check() 110 | check() 111 | 112 | def test_name_conflict(self): 113 | @durable 114 | async def durable_coroutine(): 115 | pass 116 | 117 | with self.assertRaises(ValueError): 118 | 119 | @durable 120 | async def durable_coroutine(): 121 | pass 122 | 123 | def test_two_way(self): 124 | @durable 125 | async def two_way(a): 126 | x = await yields(a) 127 | a += 1 128 | y = await yields(a) 129 | a += 1 130 | z = await yields(a) 131 | return x + y + z 132 | 133 | input = 1 134 | sends = [10, 20, 30] 135 | expect_yields = [1, 2, 3] 136 | expect_output = 60 137 | 138 | c = two_way(1) 139 | g = c.__await__() 140 | 141 | actual_yields = [] 142 | actual_return = None 143 | 144 | try: 145 | i = 0 146 | send = None 147 | while True: 148 | next_value = c.send(send) 149 | actual_yields.append(next_value) 150 | send = sends[i] 151 | i += 1 152 | except StopIteration as e: 153 | actual_return = e.value 154 | 155 | self.assertEqual(actual_yields, expect_yields) 156 | self.assertEqual(actual_return, expect_output) 157 | 158 | def test_throw(self): 159 | warnings.filterwarnings("ignore", category=DeprecationWarning) # FIXME 160 | 161 | ok = False 162 | 163 | @durable 164 | async def check_throw(): 165 | try: 166 | await yields(1) 167 | except RuntimeError: 168 | nonlocal ok 169 | ok = True 170 | 171 | c = check_throw() 172 | next(c.__await__()) 173 | try: 174 | c.throw(RuntimeError) 175 | except StopIteration: 176 | pass 177 | self.assertTrue(ok) 178 | 179 | def test_close(self): 180 | ok = False 181 | 182 | @durable 183 | async def check_close(): 184 | try: 185 | await yields(1) 186 | except GeneratorExit: 187 | nonlocal ok 188 | ok = True 189 | raise 190 | 191 | c = check_close() 192 | next(c.__await__()) 193 | c.close() 194 | self.assertTrue(ok) 195 | -------------------------------------------------------------------------------- /tests/dispatch/experimental/durable/test_frame.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from dataclasses import dataclass 3 | 4 | from dispatch.experimental.durable import frame as ext 5 | 6 | 7 | def generator(a): 8 | yield a 9 | a += 1 10 | yield a 11 | a += 1 12 | yield a 13 | 14 | 15 | @dataclass 16 | class Yields: 17 | n: int 18 | 19 | def __await__(self): 20 | yield self.n 21 | 22 | 23 | async def coroutine(a): 24 | await Yields(a) 25 | a += 1 26 | await Yields(a) 27 | a += 1 28 | await Yields(a) 29 | 30 | 31 | class TestFrame(unittest.TestCase): 32 | def test_generator_copy(self): 33 | # Create an instance and run it to the first yield point. 34 | g = generator(1) 35 | assert next(g) == 1 36 | 37 | # Copy the generator. 38 | g2 = generator(1) 39 | self.copy_to(g, g2) 40 | 41 | # The copy should start from where the previous generator was suspended. 42 | assert next(g2) == 2 43 | assert next(g2) == 3 44 | 45 | # Original generator is not affected. 46 | assert next(g) == 2 47 | assert next(g) == 3 48 | 49 | def test_coroutine_copy(self): 50 | # Create an instance and run it to the first yield point. 51 | c = coroutine(1) 52 | g = c.__await__() 53 | 54 | assert next(g) == 1 55 | 56 | # Copy the coroutine. 57 | c2 = coroutine(1) 58 | self.copy_to(c, c2) 59 | g2 = c2.__await__() 60 | 61 | # The copy should start from where the previous coroutine was suspended. 62 | assert next(g2) == 2 63 | assert next(g2) == 3 64 | 65 | # Original coroutine is not affected. 66 | assert next(g) == 2 67 | assert next(g) == 3 68 | 69 | def copy_to(self, from_obj, to_obj): 70 | ext.set_frame_state(to_obj, ext.get_frame_state(from_obj)) 71 | ext.set_frame_ip(to_obj, ext.get_frame_ip(from_obj)) 72 | ext.set_frame_sp(to_obj, ext.get_frame_sp(from_obj)) 73 | for i in range(ext.get_frame_sp(from_obj)): 74 | is_null, obj = ext.get_frame_stack_at(from_obj, i) 75 | ext.set_frame_stack_at(to_obj, i, is_null, obj) 76 | -------------------------------------------------------------------------------- /tests/dispatch/experimental/durable/test_generator.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import types 3 | import unittest 4 | import warnings 5 | 6 | from dispatch.experimental.durable import durable 7 | 8 | 9 | @durable 10 | def durable_generator(a): 11 | yield a 12 | a += 1 13 | yield a 14 | a += 1 15 | yield a 16 | 17 | 18 | @durable 19 | def nested_generators(start): 20 | yield from durable_generator(start) 21 | yield from durable_generator(start + 3) 22 | 23 | 24 | class TestGenerator(unittest.TestCase): 25 | def test_pickle(self): 26 | # Create an instance and run it to the first yield point. 27 | g = durable_generator(1) 28 | assert next(g) == 1 29 | 30 | # Copy the generator by serializing the DurableGenerator instance to bytes 31 | # and back. 32 | state = pickle.dumps(g) 33 | g2 = pickle.loads(state) 34 | 35 | # The copy should start from where the previous generator was suspended. 36 | assert next(g2) == 2 37 | assert next(g2) == 3 38 | 39 | # The original generator is not affected. 40 | assert next(g) == 2 41 | assert next(g) == 3 42 | 43 | def test_multiple_references(self): 44 | g = durable_generator(1) 45 | assert next(g) == 1 46 | 47 | state = pickle.dumps((g, g)) 48 | g2, g3 = pickle.loads(state) 49 | 50 | self.assertIs(g2, g3) 51 | self.assertIs(g2.generator, g3.generator) 52 | assert next(g2) == 2 53 | assert next(g3) == 3 54 | 55 | def test_multiple_stack_references(self): 56 | @durable 57 | def nested(): 58 | g = durable_generator(1) 59 | g2 = g 60 | yield next(g) 61 | yield next(g2) 62 | yield next(g) 63 | 64 | g = nested() 65 | self.assertEqual(next(g), 1) 66 | g2 = pickle.loads(pickle.dumps(g)) 67 | self.assertListEqual(list(g2), [2, 3]) 68 | 69 | def test_nested(self): 70 | expect = [1, 2, 3, 4, 5, 6] 71 | assert list(nested_generators(1)) == expect 72 | 73 | # Check that the generator can be pickled at every yield point. 74 | for i in range(len(expect)): 75 | # Create a generator and advance to the i'th yield point. 76 | g = nested_generators(1) 77 | for j in range(i): 78 | assert next(g) == expect[j] 79 | 80 | # Create a copy of the generator. 81 | state = pickle.dumps(g) 82 | g2 = pickle.loads(state) 83 | 84 | # Check that both the original and the copy yield the 85 | # remaining expected values. 86 | for j in range(i, len(expect)): 87 | assert next(g) == expect[j] 88 | assert next(g2) == expect[j] 89 | 90 | def test_export_gi_fields(self): 91 | g = nested_generators(1) 92 | underlying = g.generator 93 | 94 | self.assertIsInstance(g.gi_frame, types.FrameType) 95 | self.assertIs(g.gi_frame, underlying.gi_frame) 96 | 97 | self.assertIsInstance(g.gi_code, types.CodeType) 98 | self.assertIs(g.gi_code, underlying.gi_code) 99 | 100 | def check(): 101 | self.assertEqual(g.gi_running, underlying.gi_running) 102 | try: 103 | self.assertEqual(g.gi_suspended, underlying.gi_suspended) 104 | except AttributeError: 105 | pass 106 | self.assertIs(g.gi_yieldfrom, underlying.gi_yieldfrom) 107 | 108 | check() 109 | for _ in g: 110 | check() 111 | check() 112 | 113 | def test_name_conflict(self): 114 | @durable 115 | def durable_generator(): 116 | yield 1 117 | 118 | with self.assertRaises(ValueError): 119 | 120 | @durable 121 | def durable_generator(): 122 | yield 2 123 | 124 | def test_two_way(self): 125 | @durable 126 | def two_way(a): 127 | b = yield a * 10 128 | c = yield b * 10 129 | return (yield c * 10) 130 | 131 | input = 1 132 | sends = [2, 3, 4] 133 | yields = [10, 20, 30] 134 | output = 4 135 | 136 | g = two_way(1) 137 | 138 | actual_yields = [] 139 | actual_return = None 140 | 141 | try: 142 | i = 0 143 | send = None 144 | while True: 145 | next_value = g.send(send) 146 | actual_yields.append(next_value) 147 | send = sends[i] 148 | i += 1 149 | except StopIteration as e: 150 | actual_return = e.value 151 | 152 | self.assertEqual(actual_yields, yields) 153 | self.assertEqual(actual_return, output) 154 | 155 | def test_throw(self): 156 | warnings.filterwarnings("ignore", category=DeprecationWarning) # FIXME 157 | 158 | ok = False 159 | 160 | @durable 161 | def check_throw(): 162 | try: 163 | yield 164 | except RuntimeError: 165 | nonlocal ok 166 | ok = True 167 | 168 | g = check_throw() 169 | next(g) 170 | try: 171 | g.throw(RuntimeError) 172 | except StopIteration: 173 | pass 174 | self.assertTrue(ok) 175 | 176 | def test_close(self): 177 | ok = False 178 | 179 | @durable 180 | def check_close(): 181 | try: 182 | yield 183 | except GeneratorExit: 184 | nonlocal ok 185 | ok = True 186 | raise 187 | 188 | g = check_close() 189 | next(g) 190 | g.close() 191 | self.assertTrue(ok) 192 | 193 | def test_regular_function(self): 194 | @durable 195 | def regular_function(): 196 | return 1 197 | 198 | self.assertEqual(1, regular_function()) 199 | 200 | def test_asynchronous_generator(self): 201 | @durable 202 | async def async_generator(): 203 | yield 204 | 205 | with self.assertRaises(NotImplementedError): 206 | async_generator() 207 | -------------------------------------------------------------------------------- /tests/dispatch/signature/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dispatchrun/dispatch-py/53adf196e350979c6af9efe228c48f24b09ae8fb/tests/dispatch/signature/__init__.py -------------------------------------------------------------------------------- /tests/dispatch/signature/test_digest.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from dispatch.signature.digest import generate_content_digest, verify_content_digest 4 | 5 | STRINGS = ("", "x", '{"hello": "world"}') 6 | 7 | 8 | class TestDigest(unittest.TestCase): 9 | def test_sha512(self): 10 | for value in STRINGS: 11 | digest_header = generate_content_digest(value) 12 | verify_content_digest(digest_header, value) 13 | 14 | def test_known_digests(self): 15 | known = [ 16 | ( 17 | '{"hello": "world"}', 18 | "sha-512=:WZDPaVn/7XgHaAy8pmojAkGWoRx2UFChF41A2svX+TaPm+AbwAgBWnrIiYllu7BNNyealdVLvRwEmTHWXvJwew==:", 19 | ), 20 | ( 21 | '{"hello": "world"}', 22 | "sha-256=:X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=:", 23 | ), 24 | ] 25 | for value, digest_header in known: 26 | verify_content_digest(digest_header, value) 27 | -------------------------------------------------------------------------------- /tests/dispatch/signature/test_key.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from dispatch.signature import ( 4 | Ed25519PrivateKey, 5 | private_key_from_bytes, 6 | public_key_from_bytes, 7 | ) 8 | 9 | 10 | class TestKey(unittest.TestCase): 11 | def test_bytes(self): 12 | private = Ed25519PrivateKey.generate() 13 | private2 = private_key_from_bytes(private.private_bytes_raw()) 14 | self.assertEqual(private.private_bytes_raw(), private2.private_bytes_raw()) 15 | 16 | public = private.public_key() 17 | public2 = public_key_from_bytes(public.public_bytes_raw()) 18 | self.assertEqual(public.public_bytes_raw(), public2.public_bytes_raw()) 19 | -------------------------------------------------------------------------------- /tests/dispatch/test_any.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from dispatch.any import INT64_MAX, INT64_MIN, marshal_any, unmarshal_any 5 | from dispatch.sdk.v1 import error_pb2 as error_pb 6 | 7 | 8 | def test_unmarshal_none(): 9 | boxed = marshal_any(None) 10 | assert "type.googleapis.com/google.protobuf.Empty" == boxed.type_url 11 | assert None == unmarshal_any(boxed) 12 | 13 | 14 | def test_unmarshal_bool(): 15 | boxed = marshal_any(True) 16 | assert "type.googleapis.com/google.protobuf.BoolValue" == boxed.type_url 17 | assert True == unmarshal_any(boxed) 18 | 19 | 20 | def test_unmarshal_integer(): 21 | boxed = marshal_any(1234) 22 | assert "type.googleapis.com/google.protobuf.Int64Value" == boxed.type_url 23 | assert 1234 == unmarshal_any(boxed) 24 | 25 | boxed = marshal_any(-1234) 26 | assert "type.googleapis.com/google.protobuf.Int64Value" == boxed.type_url 27 | assert -1234 == unmarshal_any(boxed) 28 | 29 | 30 | def test_unmarshal_int64_limits(): 31 | boxed = marshal_any(INT64_MIN) 32 | assert "type.googleapis.com/google.protobuf.Int64Value" == boxed.type_url 33 | assert INT64_MIN == unmarshal_any(boxed) 34 | 35 | boxed = marshal_any(INT64_MAX) 36 | assert "type.googleapis.com/google.protobuf.Int64Value" == boxed.type_url 37 | assert INT64_MAX == unmarshal_any(boxed) 38 | 39 | boxed = marshal_any(INT64_MIN - 1) 40 | assert ( 41 | "buf.build/stealthrocket/dispatch-proto/dispatch.sdk.python.v1.Pickled" 42 | == boxed.type_url 43 | ) 44 | assert INT64_MIN - 1 == unmarshal_any(boxed) 45 | 46 | boxed = marshal_any(INT64_MAX + 1) 47 | assert ( 48 | "buf.build/stealthrocket/dispatch-proto/dispatch.sdk.python.v1.Pickled" 49 | == boxed.type_url 50 | ) 51 | assert INT64_MAX + 1 == unmarshal_any(boxed) 52 | 53 | 54 | def test_unmarshal_float(): 55 | boxed = marshal_any(3.14) 56 | assert "type.googleapis.com/google.protobuf.DoubleValue" == boxed.type_url 57 | assert 3.14 == unmarshal_any(boxed) 58 | 59 | 60 | def test_unmarshal_string(): 61 | boxed = marshal_any("foo") 62 | assert "type.googleapis.com/google.protobuf.StringValue" == boxed.type_url 63 | assert "foo" == unmarshal_any(boxed) 64 | 65 | 66 | def test_unmarshal_bytes(): 67 | boxed = marshal_any(b"bar") 68 | assert "type.googleapis.com/google.protobuf.BytesValue" == boxed.type_url 69 | assert b"bar" == unmarshal_any(boxed) 70 | 71 | 72 | def test_unmarshal_timestamp(): 73 | ts = datetime.fromtimestamp(1719372909.641448, timezone.utc) 74 | boxed = marshal_any(ts) 75 | assert "type.googleapis.com/google.protobuf.Timestamp" == boxed.type_url 76 | assert ts == unmarshal_any(boxed) 77 | 78 | 79 | def test_unmarshal_duration(): 80 | d = timedelta(seconds=1, microseconds=1234) 81 | boxed = marshal_any(d) 82 | assert "type.googleapis.com/google.protobuf.Duration" == boxed.type_url 83 | assert d == unmarshal_any(boxed) 84 | 85 | 86 | def test_unmarshal_protobuf_message(): 87 | message = error_pb.Error(type="internal", message="oops") 88 | boxed = marshal_any(message) 89 | 90 | # Check the message isn't pickled (in which case the type_url would 91 | # end with dispatch.sdk.python.v1.Pickled). 92 | assert ( 93 | "buf.build/stealthrocket/dispatch-proto/dispatch.sdk.v1.Error" == boxed.type_url 94 | ) 95 | 96 | assert message == unmarshal_any(boxed) 97 | 98 | 99 | def test_unmarshal_json_like(): 100 | value = { 101 | "null": None, 102 | "bool": True, 103 | "int": 11, 104 | "float": 3.14, 105 | "string": "foo", 106 | "list": [None, "abc", 1.23], 107 | "object": {"a": ["b", "c"]}, 108 | } 109 | boxed = marshal_any(value) 110 | assert "type.googleapis.com/google.protobuf.Value" == boxed.type_url 111 | assert value == unmarshal_any(boxed) 112 | -------------------------------------------------------------------------------- /tests/dispatch/test_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pickle 3 | from unittest import mock 4 | 5 | from dispatch.config import NamedValueFromEnvironment 6 | 7 | 8 | def test_value_preset(): 9 | v = NamedValueFromEnvironment("FOO", "foo", "bar") 10 | assert v.name == "foo" 11 | assert v.value == "bar" 12 | 13 | 14 | @mock.patch.dict(os.environ, {"FOO": "bar"}) 15 | def test_value_from_envvar(): 16 | v = NamedValueFromEnvironment("FOO", "foo") 17 | assert v.name == "FOO" 18 | assert v.value == "bar" 19 | 20 | 21 | @mock.patch.dict(os.environ, {"FOO": "bar"}) 22 | def test_value_pickle_reload_from_preset(): 23 | v = NamedValueFromEnvironment("FOO", "foo", "hi!") 24 | assert v.name == "foo" 25 | assert v.value == "hi!" 26 | 27 | s = pickle.dumps(v) 28 | v = pickle.loads(s) 29 | assert v.name == "foo" 30 | assert v.value == "hi!" 31 | 32 | 33 | @mock.patch.dict(os.environ, {"FOO": "bar"}) 34 | def test_value_pickle_reload_from_envvar(): 35 | v = NamedValueFromEnvironment("FOO", "foo") 36 | assert v.name == "FOO" 37 | assert v.value == "bar" 38 | 39 | s = pickle.dumps(v) 40 | os.environ["FOO"] = "baz" 41 | 42 | v = pickle.loads(s) 43 | assert v.name == "FOO" 44 | assert v.value == "baz" 45 | -------------------------------------------------------------------------------- /tests/dispatch/test_error.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | 3 | import pytest 4 | 5 | from dispatch.proto import Error, Status 6 | 7 | 8 | def test_error_with_ok_status(): 9 | with pytest.raises(ValueError): 10 | Error(Status.OK, type="type", message="yep") 11 | 12 | 13 | def test_from_exception_timeout(): 14 | err = Error.from_exception(TimeoutError()) 15 | assert Status.TIMEOUT == err.status 16 | 17 | 18 | def test_from_exception_syntax_error(): 19 | err = Error.from_exception(SyntaxError()) 20 | assert Status.PERMANENT_ERROR == err.status 21 | 22 | 23 | def test_conversion_between_exception_and_error(): 24 | try: 25 | raise ValueError("test") 26 | except Exception as e: 27 | original_exception = e 28 | error = Error.from_exception(e) 29 | original_traceback = "".join( 30 | traceback.format_exception( 31 | original_exception.__class__, 32 | original_exception, 33 | original_exception.__traceback__, 34 | ) 35 | ) 36 | 37 | # For some reasons traceback.format_exception does not include the caret 38 | # (^) in the original traceback, but it does in the reconstructed one, 39 | # so we strip it out to be able to compare the two. 40 | def strip_caret(s): 41 | return "\n".join([l for l in s.split("\n") if not l.strip().startswith("^")]) 42 | 43 | reconstructed_exception = error.to_exception() 44 | reconstructed_traceback = strip_caret( 45 | "".join( 46 | traceback.format_exception( 47 | reconstructed_exception.__class__, 48 | reconstructed_exception, 49 | reconstructed_exception.__traceback__, 50 | ) 51 | ) 52 | ) 53 | 54 | assert type(reconstructed_exception) is type(original_exception) 55 | assert str(reconstructed_exception) == str(original_exception) 56 | assert original_traceback == reconstructed_traceback 57 | 58 | error2 = Error.from_exception(reconstructed_exception) 59 | reconstructed_exception2 = error2.to_exception() 60 | reconstructed_traceback2 = strip_caret( 61 | "".join( 62 | traceback.format_exception( 63 | reconstructed_exception2.__class__, 64 | reconstructed_exception2, 65 | reconstructed_exception2.__traceback__, 66 | ) 67 | ) 68 | ) 69 | 70 | assert type(reconstructed_exception2) is type(original_exception) 71 | assert str(reconstructed_exception2) == str(original_exception) 72 | assert original_traceback == reconstructed_traceback2 73 | 74 | 75 | def test_conversion_without_traceback(): 76 | try: 77 | raise ValueError("test") 78 | except Exception as e: 79 | original_exception = e 80 | 81 | error = Error.from_exception(original_exception) 82 | error.traceback = b"" 83 | 84 | reconstructed_exception = error.to_exception() 85 | assert type(reconstructed_exception) is type(original_exception) 86 | assert str(reconstructed_exception) == str(original_exception) 87 | -------------------------------------------------------------------------------- /tests/dispatch/test_function.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | 3 | from dispatch.function import Client, Registry 4 | from dispatch.test import DISPATCH_API_KEY, DISPATCH_API_URL, DISPATCH_ENDPOINT_URL 5 | 6 | 7 | def test_serializable(): 8 | reg = Registry( 9 | name=__name__, 10 | endpoint=DISPATCH_ENDPOINT_URL, 11 | client=Client( 12 | api_key=DISPATCH_API_KEY, 13 | api_url=DISPATCH_API_URL, 14 | ), 15 | ) 16 | 17 | @reg.function 18 | def my_function(): 19 | pass 20 | 21 | s = pickle.dumps(my_function) 22 | pickle.loads(s) 23 | reg.close() 24 | -------------------------------------------------------------------------------- /tests/dispatch/test_status.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from dispatch import error 4 | from dispatch.integrations.http import http_response_code_status 5 | from dispatch.status import ( 6 | Status, 7 | register_error_type, 8 | register_output_type, 9 | status_for_error, 10 | status_for_output, 11 | ) 12 | 13 | 14 | def test_status_for_Exception(): 15 | assert status_for_error(Exception()) is Status.PERMANENT_ERROR 16 | 17 | 18 | def test_status_for_ValueError(): 19 | assert status_for_error(ValueError()) is Status.INVALID_ARGUMENT 20 | 21 | 22 | def test_status_for_TypeError(): 23 | assert status_for_error(TypeError()) is Status.INVALID_ARGUMENT 24 | 25 | 26 | def test_status_for_KeyError(): 27 | assert status_for_error(KeyError()) is Status.PERMANENT_ERROR 28 | 29 | 30 | def test_status_for_EOFError(): 31 | assert status_for_error(EOFError()) is Status.TEMPORARY_ERROR 32 | 33 | 34 | def test_status_for_ConnectionError(): 35 | assert status_for_error(ConnectionError()) is Status.TCP_ERROR 36 | 37 | 38 | def test_status_for_PermissionError(): 39 | assert status_for_error(PermissionError()) is Status.PERMISSION_DENIED 40 | 41 | 42 | def test_status_for_FileNotFoundError(): 43 | assert status_for_error(FileNotFoundError()) is Status.NOT_FOUND 44 | 45 | 46 | def test_status_for_InterruptedError(): 47 | assert status_for_error(InterruptedError()) is Status.TEMPORARY_ERROR 48 | 49 | 50 | def test_status_for_KeyboardInterrupt(): 51 | assert status_for_error(KeyboardInterrupt()) is Status.TEMPORARY_ERROR 52 | 53 | 54 | def test_status_for_OSError(): 55 | assert status_for_error(OSError()) is Status.TEMPORARY_ERROR 56 | 57 | 58 | def test_status_for_TimeoutError(): 59 | assert status_for_error(TimeoutError()) is Status.TIMEOUT 60 | 61 | 62 | def test_status_for_BaseException(): 63 | assert status_for_error(BaseException()) is Status.PERMANENT_ERROR 64 | 65 | 66 | def test_status_for_custom_error(): 67 | class CustomError(Exception): 68 | pass 69 | 70 | assert status_for_error(CustomError()) is Status.PERMANENT_ERROR 71 | 72 | 73 | def test_status_for_custom_error_with_handler(): 74 | class CustomError(Exception): 75 | pass 76 | 77 | def handler(error: Exception) -> Status: 78 | assert isinstance(error, CustomError) 79 | return Status.OK 80 | 81 | register_error_type(CustomError, handler) 82 | assert status_for_error(CustomError()) is Status.OK 83 | 84 | 85 | def test_status_for_custom_error_with_base_handler(): 86 | class CustomBaseError(Exception): 87 | pass 88 | 89 | class CustomError(CustomBaseError): 90 | pass 91 | 92 | def handler(error: Exception) -> Status: 93 | assert isinstance(error, CustomBaseError) 94 | assert isinstance(error, CustomError) 95 | return Status.TCP_ERROR 96 | 97 | register_error_type(CustomBaseError, handler) 98 | assert status_for_error(CustomError()) is Status.TCP_ERROR 99 | 100 | 101 | def test_status_for_custom_error_with_status(): 102 | class CustomError(Exception): 103 | pass 104 | 105 | register_error_type(CustomError, Status.THROTTLED) 106 | assert status_for_error(CustomError()) is Status.THROTTLED 107 | 108 | 109 | def test_status_for_custom_error_with_base_status(): 110 | class CustomBaseError(Exception): 111 | pass 112 | 113 | class CustomError(CustomBaseError): 114 | pass 115 | 116 | class CustomError2(CustomBaseError): 117 | pass 118 | 119 | register_error_type(CustomBaseError, Status.THROTTLED) 120 | register_error_type(CustomError2, Status.INVALID_ARGUMENT) 121 | assert status_for_error(CustomError()) is Status.THROTTLED 122 | assert status_for_error(CustomError2()) is Status.INVALID_ARGUMENT 123 | 124 | 125 | def test_status_for_custom_timeout(): 126 | class CustomError(TimeoutError): 127 | pass 128 | 129 | assert status_for_error(CustomError()) is Status.TIMEOUT 130 | 131 | 132 | def test_status_for_DispatchError(): 133 | assert status_for_error(error.TimeoutError()) is Status.TIMEOUT 134 | assert status_for_error(error.ThrottleError()) is Status.THROTTLED 135 | assert status_for_error(error.InvalidArgumentError()) is Status.INVALID_ARGUMENT 136 | assert status_for_error(error.InvalidResponseError()) is Status.INVALID_RESPONSE 137 | assert status_for_error(error.TemporaryError()) is Status.TEMPORARY_ERROR 138 | assert status_for_error(error.PermanentError()) is Status.PERMANENT_ERROR 139 | assert status_for_error(error.IncompatibleStateError()) is Status.INCOMPATIBLE_STATE 140 | assert status_for_error(error.DNSError()) is Status.DNS_ERROR 141 | assert status_for_error(error.TCPError()) is Status.TCP_ERROR 142 | assert status_for_error(error.HTTPError()) is Status.HTTP_ERROR 143 | assert status_for_error(error.UnauthenticatedError()) is Status.UNAUTHENTICATED 144 | assert status_for_error(error.PermissionDeniedError()) is Status.PERMISSION_DENIED 145 | assert status_for_error(error.NotFoundError()) is Status.NOT_FOUND 146 | assert status_for_error(error.DispatchError()) is Status.UNSPECIFIED 147 | 148 | 149 | def test_status_for_custom_output(): 150 | class CustomOutput: 151 | pass 152 | 153 | assert status_for_output(CustomOutput()) is Status.OK # default 154 | 155 | 156 | def test_status_for_custom_output_with_handler(): 157 | class CustomOutput: 158 | pass 159 | 160 | def handler(output: Any) -> Status: 161 | assert isinstance(output, CustomOutput) 162 | return Status.DNS_ERROR 163 | 164 | register_output_type(CustomOutput, handler) 165 | assert status_for_output(CustomOutput()) is Status.DNS_ERROR 166 | 167 | 168 | def test_status_for_custom_output_with_base_handler(): 169 | class CustomOutputBase: 170 | pass 171 | 172 | class CustomOutputError(CustomOutputBase): 173 | pass 174 | 175 | class CustomOutputSuccess(CustomOutputBase): 176 | pass 177 | 178 | def handler(output: Any) -> Status: 179 | assert isinstance(output, CustomOutputBase) 180 | if isinstance(output, CustomOutputError): 181 | return Status.DNS_ERROR 182 | assert isinstance(output, CustomOutputSuccess) 183 | return Status.OK 184 | 185 | register_output_type(CustomOutputBase, handler) 186 | assert status_for_output(CustomOutputSuccess()) is Status.OK 187 | assert status_for_output(CustomOutputError()) is Status.DNS_ERROR 188 | 189 | 190 | def test_status_for_custom_output_with_status(): 191 | class CustomOutputBase: 192 | pass 193 | 194 | class CustomOutputChild1(CustomOutputBase): 195 | pass 196 | 197 | class CustomOutputChild2(CustomOutputBase): 198 | pass 199 | 200 | register_output_type(CustomOutputBase, Status.PERMISSION_DENIED) 201 | register_output_type(CustomOutputChild1, Status.TCP_ERROR) 202 | assert status_for_output(CustomOutputChild1()) is Status.TCP_ERROR 203 | assert status_for_output(CustomOutputChild2()) is Status.PERMISSION_DENIED 204 | 205 | 206 | def test_status_for_custom_output_with_base_status(): 207 | class CustomOutput(Exception): 208 | pass 209 | 210 | register_output_type(CustomOutput, Status.THROTTLED) 211 | assert status_for_output(CustomOutput()) is Status.THROTTLED 212 | 213 | 214 | def test_http_response_code_status_400(): 215 | assert http_response_code_status(400) is Status.INVALID_ARGUMENT 216 | 217 | 218 | def test_http_response_code_status_401(): 219 | assert http_response_code_status(401) is Status.UNAUTHENTICATED 220 | 221 | 222 | def test_http_response_code_status_403(): 223 | assert http_response_code_status(403) is Status.PERMISSION_DENIED 224 | 225 | 226 | def test_http_response_code_status_404(): 227 | assert http_response_code_status(404) is Status.NOT_FOUND 228 | 229 | 230 | def test_http_response_code_status_408(): 231 | assert http_response_code_status(408) is Status.TIMEOUT 232 | 233 | 234 | def test_http_response_code_status_429(): 235 | assert http_response_code_status(429) is Status.THROTTLED 236 | 237 | 238 | def test_http_response_code_status_501(): 239 | assert http_response_code_status(501) is Status.PERMANENT_ERROR 240 | 241 | 242 | def test_http_response_code_status_1xx(): 243 | for status in range(100, 200): 244 | assert http_response_code_status(100) is Status.PERMANENT_ERROR 245 | 246 | 247 | def test_http_response_code_status_2xx(): 248 | for status in range(200, 300): 249 | assert http_response_code_status(200) is Status.OK 250 | 251 | 252 | def test_http_response_code_status_3xx(): 253 | for status in range(300, 400): 254 | assert http_response_code_status(300) is Status.PERMANENT_ERROR 255 | 256 | 257 | def test_http_response_code_status_4xx(): 258 | for status in range(400, 500): 259 | if status not in (400, 401, 403, 404, 408, 429, 501): 260 | assert http_response_code_status(status) is Status.PERMANENT_ERROR 261 | 262 | 263 | def test_http_response_code_status_5xx(): 264 | for status in range(500, 600): 265 | if status not in (501,): 266 | assert http_response_code_status(status) is Status.TEMPORARY_ERROR 267 | 268 | 269 | def test_http_response_code_status_6xx(): 270 | for status in range(600, 700): 271 | assert http_response_code_status(600) is Status.UNSPECIFIED 272 | -------------------------------------------------------------------------------- /tests/test_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | import dispatch.test 7 | from dispatch import Call 8 | from dispatch.test import Client 9 | 10 | 11 | def server() -> dispatch.test.Server: 12 | return dispatch.test.Server(dispatch.test.Service()) 13 | 14 | 15 | @mock.patch.dict(os.environ, {"DISPATCH_API_KEY": ""}) 16 | def test_api_key_missing(): 17 | with pytest.raises(ValueError) as mc: 18 | Client() 19 | assert ( 20 | str(mc.value) 21 | == "missing API key: set it with the DISPATCH_API_KEY environment variable" 22 | ) 23 | 24 | 25 | def test_url_bad_scheme(): 26 | with pytest.raises(ValueError) as mc: 27 | Client(api_url="ftp://example.com", api_key="foo") 28 | assert str(mc.value) == "Invalid API scheme: 'ftp'" 29 | 30 | 31 | def test_can_be_constructed_on_https(): 32 | # Goal is to not raise an exception here. We don't have an HTTPS server 33 | # around to actually test this. 34 | Client(api_url="https://example.com", api_key="foo") 35 | 36 | 37 | # On Python 3.8/3.9, pytest.mark.asyncio doesn't work with mock.patch.dict, 38 | # so we have to use the old-fashioned way of setting the environment variable 39 | # and then cleaning it up manually. 40 | # 41 | # @mock.patch.dict(os.environ, {"DISPATCH_API_KEY": "0000000000000000"}) 42 | @pytest.mark.asyncio 43 | async def test_api_key_from_env(): 44 | prev_api_key = os.environ.get("DISPATCH_API_KEY") 45 | try: 46 | os.environ["DISPATCH_API_KEY"] = "0000000000000000" 47 | async with server() as api: 48 | client = Client(api_url=api.url) 49 | 50 | with pytest.raises( 51 | PermissionError, 52 | match=r"Dispatch received an invalid authentication token \(check DISPATCH_API_KEY is correct\)", 53 | ) as mc: 54 | await client.dispatch([Call(function="my-function", input=42)]) 55 | finally: 56 | if prev_api_key is None: 57 | del os.environ["DISPATCH_API_KEY"] 58 | else: 59 | os.environ["DISPATCH_API_KEY"] = prev_api_key 60 | 61 | 62 | @pytest.mark.asyncio 63 | async def test_api_key_from_arg(): 64 | async with server() as api: 65 | client = Client(api_url=api.url, api_key="WHATEVER") 66 | 67 | with pytest.raises( 68 | PermissionError, 69 | match=r"Dispatch received an invalid authentication token \(check api_key is correct\)", 70 | ) as mc: 71 | await client.dispatch([Call(function="my-function", input=42)]) 72 | -------------------------------------------------------------------------------- /tests/test_fastapi.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | import sys 4 | 5 | import fastapi 6 | import google.protobuf.any_pb2 7 | import google.protobuf.wrappers_pb2 8 | import httpx 9 | import uvicorn 10 | from cryptography.hazmat.primitives.asymmetric.ed25519 import ( 11 | Ed25519PrivateKey, 12 | Ed25519PublicKey, 13 | ) 14 | from fastapi import FastAPI 15 | from fastapi.testclient import TestClient 16 | 17 | import dispatch 18 | from dispatch.asyncio import Runner 19 | from dispatch.experimental.durable.registry import clear_functions 20 | from dispatch.fastapi import Dispatch 21 | from dispatch.function import Arguments, Client, Error, Input, Output, Registry 22 | from dispatch.sdk.v1 import call_pb2 as call_pb 23 | from dispatch.sdk.v1 import function_pb2 as function_pb 24 | from dispatch.signature import ( 25 | parse_verification_key, 26 | private_key_from_pem, 27 | public_key_from_pem, 28 | ) 29 | from dispatch.status import Status 30 | 31 | 32 | class TestFastAPI(dispatch.test.TestCase): 33 | 34 | def dispatch_test_init(self, reg: Registry) -> str: 35 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 36 | sock.bind(("127.0.0.1", 0)) 37 | sock.listen(128) 38 | 39 | (host, port) = sock.getsockname() 40 | 41 | app = FastAPI() 42 | dispatch = Dispatch(app, registry=reg) 43 | 44 | config = uvicorn.Config(app, host=host, port=port) 45 | self.sockets = [sock] 46 | self.uvicorn = uvicorn.Server(config) 47 | self.runner = Runner() 48 | if sys.version_info >= (3, 10): 49 | self.event = asyncio.Event() 50 | else: 51 | self.event = asyncio.Event(loop=self.runner.get_loop()) 52 | return f"http://{host}:{port}" 53 | 54 | def dispatch_test_run(self): 55 | loop = self.runner.get_loop() 56 | loop.create_task(self.uvicorn.serve(self.sockets)) 57 | self.runner.run(self.event.wait()) 58 | self.runner.close() 59 | 60 | for sock in self.sockets: 61 | sock.close() 62 | 63 | def dispatch_test_stop(self): 64 | loop = self.runner.get_loop() 65 | loop.call_soon_threadsafe(self.event.set) 66 | -------------------------------------------------------------------------------- /tests/test_flask.py: -------------------------------------------------------------------------------- 1 | from wsgiref.simple_server import make_server 2 | 3 | from flask import Flask 4 | 5 | import dispatch 6 | import dispatch.test 7 | from dispatch.flask import Dispatch 8 | from dispatch.function import Registry 9 | 10 | 11 | class TestFlask(dispatch.test.TestCase): 12 | 13 | def dispatch_test_init(self, reg: Registry) -> str: 14 | host = "127.0.0.1" 15 | port = 56789 16 | 17 | app = Flask("test") 18 | dispatch = Dispatch(app, registry=reg) 19 | 20 | self.wsgi = make_server(host, port, app) 21 | return f"http://{host}:{port}" 22 | 23 | def dispatch_test_run(self): 24 | self.wsgi.serve_forever(poll_interval=0.05) 25 | 26 | def dispatch_test_stop(self): 27 | self.wsgi.shutdown() 28 | self.wsgi.server_close() 29 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import socket 3 | import sys 4 | from http.server import HTTPServer 5 | 6 | import dispatch.test 7 | from dispatch.asyncio import Runner 8 | from dispatch.function import Registry 9 | from dispatch.http import Dispatch, Server 10 | 11 | 12 | class TestHTTP(dispatch.test.TestCase): 13 | 14 | def dispatch_test_init(self, reg: Registry) -> str: 15 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 16 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 17 | sock.bind(("127.0.0.1", 0)) 18 | sock.listen(128) 19 | 20 | (host, port) = sock.getsockname() 21 | 22 | self.httpserver = HTTPServer( 23 | server_address=(host, port), 24 | RequestHandlerClass=Dispatch(reg), 25 | bind_and_activate=False, 26 | ) 27 | self.httpserver.socket = sock 28 | return f"http://{host}:{port}" 29 | 30 | def dispatch_test_run(self): 31 | self.httpserver.serve_forever(poll_interval=0.05) 32 | 33 | def dispatch_test_stop(self): 34 | self.httpserver.shutdown() 35 | self.httpserver.server_close() 36 | self.httpserver.socket.close() 37 | 38 | 39 | class TestAIOHTTP(dispatch.test.TestCase): 40 | 41 | def dispatch_test_init(self, reg: Registry) -> str: 42 | host = "127.0.0.1" 43 | port = 0 44 | 45 | self.aioloop = Runner() 46 | self.aiohttp = Server(host, port, Dispatch(reg)) 47 | self.aioloop.run(self.aiohttp.start()) 48 | 49 | if sys.version_info >= (3, 10): 50 | self.aiowait = asyncio.Event() 51 | else: 52 | self.aiowait = asyncio.Event(loop=self.aioloop.get_loop()) 53 | 54 | return f"http://{self.aiohttp.host}:{self.aiohttp.port}" 55 | 56 | def dispatch_test_run(self): 57 | self.aioloop.run(self.aiowait.wait()) 58 | self.aioloop.run(self.aiohttp.stop()) 59 | self.aioloop.close() 60 | 61 | def dispatch_test_stop(self): 62 | self.aioloop.get_loop().call_soon_threadsafe(self.aiowait.set) 63 | --------------------------------------------------------------------------------