fusion 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fusion-0.1.0/.github/workflows/pypi-publish.yml +71 -0
- fusion-0.1.0/.gitignore +168 -0
- fusion-0.1.0/.pre-commit-config.yaml +49 -0
- fusion-0.1.0/CHANGELOG.md +0 -0
- fusion-0.1.0/LICENSE.md +0 -0
- fusion-0.1.0/PKG-INFO +21 -0
- fusion-0.1.0/README.md +0 -0
- fusion-0.1.0/pyproject.toml +106 -0
- fusion-0.1.0/src/fusion/__init__.py +5 -0
- fusion-0.1.0/src/fusion/annotations.py +15 -0
- fusion-0.1.0/src/fusion/application.py +11 -0
- fusion-0.1.0/src/fusion/context.py +28 -0
- fusion-0.1.0/src/fusion/di.py +102 -0
- fusion-0.1.0/src/fusion/endpoints.py +17 -0
- fusion-0.1.0/src/fusion/exceptions.py +2 -0
- fusion-0.1.0/src/fusion/resolvers.py +140 -0
- fusion-0.1.0/src/fusion/routing.py +42 -0
- fusion-0.1.0/tests/__init__.py +0 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# This workflow will upload a Python Package using Twine when a release is created
|
|
2
|
+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
|
|
3
|
+
|
|
4
|
+
# This workflow uses actions that are not certified by GitHub.
|
|
5
|
+
# They are provided by a third-party and are governed by
|
|
6
|
+
# separate terms of service, privacy policy, and support
|
|
7
|
+
# documentation.
|
|
8
|
+
|
|
9
|
+
name: Upload Python Package
|
|
10
|
+
|
|
11
|
+
on:
|
|
12
|
+
release:
|
|
13
|
+
types: [published]
|
|
14
|
+
|
|
15
|
+
permissions:
|
|
16
|
+
contents: read
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
release-build:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-python@v5
|
|
26
|
+
with:
|
|
27
|
+
python-version: "3.12"
|
|
28
|
+
|
|
29
|
+
- name: Build release distributions
|
|
30
|
+
run: |
|
|
31
|
+
# NOTE: put your own distribution build steps here.
|
|
32
|
+
python -m pip install build
|
|
33
|
+
python -m build
|
|
34
|
+
|
|
35
|
+
- name: Upload distributions
|
|
36
|
+
uses: actions/upload-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: release-dists
|
|
39
|
+
path: dist/
|
|
40
|
+
|
|
41
|
+
pypi-publish:
|
|
42
|
+
runs-on: ubuntu-latest
|
|
43
|
+
|
|
44
|
+
needs:
|
|
45
|
+
- release-build
|
|
46
|
+
|
|
47
|
+
permissions:
|
|
48
|
+
# IMPORTANT: this permission is mandatory for trusted publishing
|
|
49
|
+
id-token: write
|
|
50
|
+
|
|
51
|
+
#environment:
|
|
52
|
+
# name: testpypi
|
|
53
|
+
# url: https://test.pypi.org/p/fusion
|
|
54
|
+
environment:
|
|
55
|
+
name: pypi
|
|
56
|
+
url: https://pypi.org/p/fusion
|
|
57
|
+
|
|
58
|
+
steps:
|
|
59
|
+
- name: Retrieve release distributions
|
|
60
|
+
uses: actions/download-artifact@v4
|
|
61
|
+
with:
|
|
62
|
+
name: release-dists
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
- name: Publish release distributions to PyPI
|
|
66
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
67
|
+
|
|
68
|
+
#- name: Publish release distributions to TestPyPI
|
|
69
|
+
# uses: pypa/gh-action-pypi-publish@release/v1
|
|
70
|
+
# with:
|
|
71
|
+
# repository-url: https://test.pypi.org/legacy/
|
fusion-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py,cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# poetry
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
102
|
+
#poetry.lock
|
|
103
|
+
|
|
104
|
+
# pdm
|
|
105
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
106
|
+
#pdm.lock
|
|
107
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
108
|
+
# in version control.
|
|
109
|
+
# https://pdm.fming.dev/#use-with-ide
|
|
110
|
+
.pdm.toml
|
|
111
|
+
|
|
112
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
113
|
+
__pypackages__/
|
|
114
|
+
|
|
115
|
+
# Celery stuff
|
|
116
|
+
celerybeat-schedule
|
|
117
|
+
celerybeat.pid
|
|
118
|
+
|
|
119
|
+
# SageMath parsed files
|
|
120
|
+
*.sage.py
|
|
121
|
+
|
|
122
|
+
# Environments
|
|
123
|
+
.env
|
|
124
|
+
.venv
|
|
125
|
+
env/
|
|
126
|
+
venv/
|
|
127
|
+
ENV/
|
|
128
|
+
env.bak/
|
|
129
|
+
venv.bak/
|
|
130
|
+
.vscode/
|
|
131
|
+
|
|
132
|
+
# Spyder project settings
|
|
133
|
+
.spyderproject
|
|
134
|
+
.spyproject
|
|
135
|
+
|
|
136
|
+
# Rope project settings
|
|
137
|
+
.ropeproject
|
|
138
|
+
|
|
139
|
+
# mkdocs documentation
|
|
140
|
+
/site
|
|
141
|
+
|
|
142
|
+
# mypy
|
|
143
|
+
.mypy_cache/
|
|
144
|
+
.dmypy.json
|
|
145
|
+
dmypy.json
|
|
146
|
+
|
|
147
|
+
# ruff
|
|
148
|
+
.ruff_cache/
|
|
149
|
+
|
|
150
|
+
# Pyre type checker
|
|
151
|
+
.pyre/
|
|
152
|
+
|
|
153
|
+
# pytype static type analyzer
|
|
154
|
+
.pytype/
|
|
155
|
+
|
|
156
|
+
# Cython debug symbols
|
|
157
|
+
cython_debug/
|
|
158
|
+
|
|
159
|
+
# PyCharm
|
|
160
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
161
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
162
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
163
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
164
|
+
#.idea/
|
|
165
|
+
|
|
166
|
+
# Zed code editor
|
|
167
|
+
# pyright
|
|
168
|
+
pyrightconfig.json
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
default_language_version:
|
|
2
|
+
python: "3.12"
|
|
3
|
+
repos:
|
|
4
|
+
- hooks:
|
|
5
|
+
- args:
|
|
6
|
+
- --strict
|
|
7
|
+
- feat
|
|
8
|
+
- fix
|
|
9
|
+
- chore
|
|
10
|
+
- test
|
|
11
|
+
- bump
|
|
12
|
+
id: conventional-pre-commit
|
|
13
|
+
stages:
|
|
14
|
+
- commit-msg
|
|
15
|
+
repo: https://github.com/compilerla/conventional-pre-commit
|
|
16
|
+
rev: v3.0.0
|
|
17
|
+
- hooks:
|
|
18
|
+
- id: check-yaml
|
|
19
|
+
- id: end-of-file-fixer
|
|
20
|
+
- id: trailing-whitespace
|
|
21
|
+
repo: https://github.com/pre-commit/pre-commit-hooks
|
|
22
|
+
rev: v2.3.0
|
|
23
|
+
- hooks:
|
|
24
|
+
- args:
|
|
25
|
+
- --fix
|
|
26
|
+
id: ruff
|
|
27
|
+
- id: ruff-format
|
|
28
|
+
repo: https://github.com/astral-sh/ruff-pre-commit
|
|
29
|
+
rev: v0.1.8
|
|
30
|
+
- hooks:
|
|
31
|
+
- additional_dependencies:
|
|
32
|
+
- bandit[toml]
|
|
33
|
+
args:
|
|
34
|
+
- -c
|
|
35
|
+
- pyproject.toml
|
|
36
|
+
- --exclude
|
|
37
|
+
- tests/*
|
|
38
|
+
id: bandit
|
|
39
|
+
repo: https://github.com/PyCQA/bandit
|
|
40
|
+
rev: 1.7.6
|
|
41
|
+
|
|
42
|
+
- repo: local
|
|
43
|
+
hooks:
|
|
44
|
+
- id: pytest-check
|
|
45
|
+
name: pytest-check
|
|
46
|
+
entry: pytest --cov
|
|
47
|
+
language: system
|
|
48
|
+
pass_filenames: false
|
|
49
|
+
always_run: true
|
|
File without changes
|
fusion-0.1.0/LICENSE.md
ADDED
|
File without changes
|
fusion-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fusion
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fusion is a modern ASGI web framework for Python with built-in dependency injection, OpenAPI schema generation, and MCP support.
|
|
5
|
+
Project-URL: Homepage, https://github.com/okanakbulut/fusion
|
|
6
|
+
Author: Okan Akbulut
|
|
7
|
+
License-File: LICENSE.md
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Requires-Dist: msgspec==0.19.0
|
|
10
|
+
Requires-Dist: starlette==0.47.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: commitizen==4.8.2; extra == 'dev'
|
|
13
|
+
Requires-Dist: coverage[toml]>=6.5; extra == 'dev'
|
|
14
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
15
|
+
Requires-Dist: ipython; extra == 'dev'
|
|
16
|
+
Requires-Dist: pre-commit==4.2.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
20
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
21
|
+
Requires-Dist: ruff==0.11.12; extra == 'dev'
|
fusion-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fusion"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Fusion is a modern ASGI web framework for Python with built-in dependency injection, OpenAPI schema generation, and MCP support."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
license = { file = "LICENSE.md" }
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Okan Akbulut" }
|
|
10
|
+
]
|
|
11
|
+
dependencies = [
|
|
12
|
+
"msgspec==0.19.0",
|
|
13
|
+
"starlette==0.47.0"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Homepage = "https://github.com/okanakbulut/fusion"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"coverage[toml]>=6.5",
|
|
23
|
+
"pytest",
|
|
24
|
+
"pytest-asyncio",
|
|
25
|
+
"pytest-cov",
|
|
26
|
+
"pytest-mock",
|
|
27
|
+
"pre-commit==4.2.0",
|
|
28
|
+
"ruff==0.11.12",
|
|
29
|
+
"commitizen==4.8.2",
|
|
30
|
+
"ipython",
|
|
31
|
+
"httpx",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[build-system]
|
|
35
|
+
requires = ["hatchling"]
|
|
36
|
+
build-backend = "hatchling.build"
|
|
37
|
+
|
|
38
|
+
[tool.ruff]
|
|
39
|
+
line-length = 100
|
|
40
|
+
indent-width = 4
|
|
41
|
+
target-version = "py312"
|
|
42
|
+
src = ["src", "tests"]
|
|
43
|
+
unfixable = ["F401"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
fixable = ["ALL"]
|
|
48
|
+
extend-select = ["C90", "I", "F", "E", "ASYNC"]
|
|
49
|
+
extend-ignore = ["F401"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.per-file-ignores]
|
|
52
|
+
"**/__init__.py" = ["F401"]
|
|
53
|
+
"**/tests/**" = ["D101", "D102", "D103", "F", "E501"]
|
|
54
|
+
"**/tests/sql/**" = ["E501"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint.pydocstyle]
|
|
58
|
+
convention = "google"
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint.mccabe]
|
|
61
|
+
# Flag errors (`C901`) whenever the complexity level exceeds 15.
|
|
62
|
+
max-complexity = 15
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
[tool.commitizen]
|
|
66
|
+
name = "cz_conventional_commits"
|
|
67
|
+
tag_format = "v$version"
|
|
68
|
+
version_scheme = "semver"
|
|
69
|
+
version_provider = "pep621"
|
|
70
|
+
update_changelog_on_bump = true
|
|
71
|
+
major_version_zero = true
|
|
72
|
+
|
|
73
|
+
[tool.pytest.ini_options]
|
|
74
|
+
addopts = [
|
|
75
|
+
"--import-mode=importlib",
|
|
76
|
+
"--doctest-modules",
|
|
77
|
+
"--quiet",
|
|
78
|
+
"--tb=long",
|
|
79
|
+
]
|
|
80
|
+
testpaths = ["tests", "src/fusion"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
branch = true
|
|
84
|
+
omit = ["tests/*"]
|
|
85
|
+
|
|
86
|
+
[tool.coverage.report]
|
|
87
|
+
exclude_lines = [
|
|
88
|
+
"pragma: no cover",
|
|
89
|
+
"def __repr__",
|
|
90
|
+
"def __str__",
|
|
91
|
+
"if self.debug",
|
|
92
|
+
"if settings.DEBUG",
|
|
93
|
+
"raise AssertionError",
|
|
94
|
+
"raise NotImplementedError",
|
|
95
|
+
"if 0:",
|
|
96
|
+
"if __name__ == .__main__.:",
|
|
97
|
+
]
|
|
98
|
+
#fail_under = 100
|
|
99
|
+
#show_missing = true
|
|
100
|
+
#skip_covered = true
|
|
101
|
+
|
|
102
|
+
[tool.pyright]
|
|
103
|
+
venvPath = "/Users/okanakbulut/.virtualenvs"
|
|
104
|
+
venv = "fusion"
|
|
105
|
+
pythonVersion = "3.12"
|
|
106
|
+
exclude = ["**/__init__.py", "**/__pycache__"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
|
|
3
|
+
from fusion.resolvers import (
|
|
4
|
+
CookieResolver,
|
|
5
|
+
HeaderResolver,
|
|
6
|
+
PathParamResolver,
|
|
7
|
+
QueryParamResolver,
|
|
8
|
+
RequestBodyResolver,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
type PathParam[T] = Annotated[T, {"resolver": PathParamResolver}]
|
|
12
|
+
type QueryParam[T] = Annotated[T, {"resolver": QueryParamResolver}]
|
|
13
|
+
type Header[T] = Annotated[T, {"resolver": HeaderResolver}]
|
|
14
|
+
type Cookie[T] = Annotated[T, {"resolver": CookieResolver}]
|
|
15
|
+
type RequestBody[O] = Annotated[O, {"resolver": RequestBodyResolver}]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from starlette.applications import Starlette
|
|
2
|
+
|
|
3
|
+
from fusion.routing import Router
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Fusion(Starlette):
|
|
7
|
+
"""Fusion application that integrates dependency injection."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, routes, middleware=None, lifespan=None):
|
|
10
|
+
super().__init__(routes=routes, middleware=middleware, lifespan=lifespan)
|
|
11
|
+
self.router = Router(routes=routes, lifespan=lifespan)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from contextlib import AsyncExitStack
|
|
2
|
+
from contextvars import ContextVar, Token
|
|
3
|
+
from typing import Self
|
|
4
|
+
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
|
|
7
|
+
context: ContextVar["Context"] = ContextVar("context")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Context(AsyncExitStack):
|
|
11
|
+
_token: Token
|
|
12
|
+
request: Request
|
|
13
|
+
|
|
14
|
+
def __init__(self, request: Request):
|
|
15
|
+
super().__init__()
|
|
16
|
+
self.request = request
|
|
17
|
+
|
|
18
|
+
async def __aenter__(self) -> Self:
|
|
19
|
+
if context.get(None) is not None:
|
|
20
|
+
raise RuntimeError("Nested context is not allowed")
|
|
21
|
+
self._token = context.set(self)
|
|
22
|
+
return await super().__aenter__()
|
|
23
|
+
|
|
24
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: # type:ignore
|
|
25
|
+
try:
|
|
26
|
+
await super().__aexit__(exc_type, exc_val, exc_tb)
|
|
27
|
+
finally:
|
|
28
|
+
context.reset(self._token)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from collections.abc import AsyncIterator
|
|
2
|
+
from contextlib import AbstractAsyncContextManager
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Any, Callable, ClassVar, Self, TypeVar, get_origin
|
|
5
|
+
|
|
6
|
+
from msgspec import Struct as Object
|
|
7
|
+
|
|
8
|
+
from fusion.resolvers import (
|
|
9
|
+
Constructor,
|
|
10
|
+
FactoryResolver,
|
|
11
|
+
InjectableResolver,
|
|
12
|
+
Resolver,
|
|
13
|
+
__factories__,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Injectable(Object):
|
|
20
|
+
__resolvers__: ClassVar[list[Resolver]]
|
|
21
|
+
|
|
22
|
+
def __init_subclass__(cls, *args, **kwargs):
|
|
23
|
+
cls.__resolvers__ = build_resolvers(cls.__annotations__)
|
|
24
|
+
super().__init_subclass__(*args, **kwargs)
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
async def instance(cls) -> Self:
|
|
28
|
+
"""Create an instance of the class with all dependencies resolved."""
|
|
29
|
+
params = {}
|
|
30
|
+
for resolver in cls.__resolvers__:
|
|
31
|
+
name, value = await resolver.resolve()
|
|
32
|
+
params[name] = value
|
|
33
|
+
|
|
34
|
+
return cls(**params)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def factory(func: Constructor) -> Constructor:
|
|
38
|
+
"""Decorator to register a factory function for a type."""
|
|
39
|
+
if "return" not in func.__annotations__:
|
|
40
|
+
raise ValueError("Factory function must have a return type annotation")
|
|
41
|
+
# Register the factory function
|
|
42
|
+
return_annotation = func.__annotations__["return"]
|
|
43
|
+
origin = get_origin(return_annotation)
|
|
44
|
+
return_type = return_annotation.__args__[0] if origin is AsyncIterator else return_annotation
|
|
45
|
+
__factories__[return_type] = func
|
|
46
|
+
return func
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_resolvers(annotations: dict[str, Any]) -> list[Resolver]:
|
|
50
|
+
resolvers = []
|
|
51
|
+
for name, annotation in annotations.items():
|
|
52
|
+
origin = get_origin(annotation)
|
|
53
|
+
if not origin:
|
|
54
|
+
if issubclass(annotation, Injectable):
|
|
55
|
+
resolvers.append(InjectableResolver(name=name, typ=annotation))
|
|
56
|
+
elif annotation in __factories__:
|
|
57
|
+
resolvers.append(FactoryResolver(name=name, typ=annotation))
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
60
|
+
continue
|
|
61
|
+
# skip if annotation is ClassVar
|
|
62
|
+
if origin is ClassVar:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if len(annotation.__args__) != 1:
|
|
66
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
67
|
+
|
|
68
|
+
typ = annotation.__args__[0]
|
|
69
|
+
annotated = origin.__value__
|
|
70
|
+
if not annotated:
|
|
71
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
72
|
+
|
|
73
|
+
if not hasattr(annotated, "__metadata__"):
|
|
74
|
+
raise ValueError(f"Invalid annotation for {name}: {annotation}")
|
|
75
|
+
|
|
76
|
+
metadata = annotated.__metadata__[0]
|
|
77
|
+
DependencyResolver = metadata.get("resolver", None)
|
|
78
|
+
if not DependencyResolver:
|
|
79
|
+
raise ValueError(f"No resolver found for {name}: {annotation}")
|
|
80
|
+
|
|
81
|
+
if not issubclass(DependencyResolver, Resolver):
|
|
82
|
+
raise ValueError(f"Invalid resolver for {name}: {annotation}")
|
|
83
|
+
|
|
84
|
+
resolvers.append(DependencyResolver(name=name, typ=typ))
|
|
85
|
+
|
|
86
|
+
return resolvers
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def inject(func: Callable) -> Callable:
|
|
90
|
+
"""Decorator to mark a function as an injector."""
|
|
91
|
+
resolvers = build_resolvers(func.__annotations__)
|
|
92
|
+
|
|
93
|
+
@wraps(func)
|
|
94
|
+
async def wrapper(self, *args, **kwargs):
|
|
95
|
+
params = {}
|
|
96
|
+
for resolver in resolvers:
|
|
97
|
+
name, value = await resolver.resolve()
|
|
98
|
+
params[name] = value
|
|
99
|
+
# Call the original function with resolved parameters
|
|
100
|
+
return await func(self, **params)
|
|
101
|
+
|
|
102
|
+
return wrapper
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from typing import ClassVar
|
|
2
|
+
|
|
3
|
+
from fusion.di import Injectable, inject
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HttpEndpoint(Injectable):
|
|
7
|
+
methods: ClassVar[list[str]]
|
|
8
|
+
|
|
9
|
+
def __init_subclass__(cls, *args, **kwargs):
|
|
10
|
+
cls.methods = []
|
|
11
|
+
for http_method in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"):
|
|
12
|
+
method = getattr(cls, http_method.lower(), None)
|
|
13
|
+
if callable(method):
|
|
14
|
+
cls.methods.append(http_method)
|
|
15
|
+
setattr(cls, http_method.lower(), inject(method))
|
|
16
|
+
|
|
17
|
+
super().__init_subclass__(*args, **kwargs)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from abc import abstractmethod
|
|
2
|
+
from collections.abc import AsyncIterator, Awaitable
|
|
3
|
+
from contextlib import AbstractAsyncContextManager
|
|
4
|
+
from typing import (
|
|
5
|
+
Callable,
|
|
6
|
+
Generic,
|
|
7
|
+
Protocol,
|
|
8
|
+
Self,
|
|
9
|
+
Type,
|
|
10
|
+
TypeVar,
|
|
11
|
+
get_origin,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import msgspec
|
|
15
|
+
from msgspec import Struct as Object
|
|
16
|
+
|
|
17
|
+
from fusion.context import context
|
|
18
|
+
|
|
19
|
+
T = TypeVar("T")
|
|
20
|
+
type Constructor[T] = Callable[[], Awaitable[T] | AbstractAsyncContextManager[T]]
|
|
21
|
+
__factories__: dict[Type, Constructor] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InjectableObject(Protocol):
|
|
25
|
+
@classmethod
|
|
26
|
+
async def instance(cls) -> Self:
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Resolver(Object, Generic[T]):
|
|
31
|
+
"""Base class for resolvers."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
typ: Type[T]
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
38
|
+
"""Resolve the dependency."""
|
|
39
|
+
raise NotImplementedError("Subclasses must implement this method")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InjectableResolver(Resolver[InjectableObject]):
|
|
43
|
+
"""Resolver for injected dependencies."""
|
|
44
|
+
|
|
45
|
+
async def resolve(self) -> tuple[str, InjectableObject]:
|
|
46
|
+
"""Resolve the injected dependency."""
|
|
47
|
+
ctx = context.get()
|
|
48
|
+
if not ctx:
|
|
49
|
+
raise RuntimeError("Request context is not available")
|
|
50
|
+
|
|
51
|
+
return self.name, await self.typ.instance()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def isasynccontextmanager(func: Callable) -> bool:
|
|
55
|
+
assert hasattr(func, "__annotations__")
|
|
56
|
+
ret = func.__annotations__.get("return", None)
|
|
57
|
+
return get_origin(ret) is AsyncIterator if ret else False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class FactoryResolver(Resolver[T]):
|
|
61
|
+
"""Resolver for factory functions."""
|
|
62
|
+
|
|
63
|
+
async def resolve(self) -> tuple[str, T]:
|
|
64
|
+
"""Resolve the factory function."""
|
|
65
|
+
factory: Constructor | None = __factories__.get(self.typ)
|
|
66
|
+
if factory is None:
|
|
67
|
+
raise ValueError(f"No factory found for {self.typ}")
|
|
68
|
+
|
|
69
|
+
if isasynccontextmanager(factory):
|
|
70
|
+
ctx = context.get()
|
|
71
|
+
return self.name, await ctx.enter_async_context(factory()) # type: ignore
|
|
72
|
+
else:
|
|
73
|
+
return self.name, await factory() # type: ignore
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class QueryParamResolver(Resolver[T]):
|
|
77
|
+
"""Resolver for query parameters."""
|
|
78
|
+
|
|
79
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
80
|
+
"""Resolve the query parameter from the request context."""
|
|
81
|
+
ctx = context.get()
|
|
82
|
+
value = ctx.request.query_params.get(self.name, None)
|
|
83
|
+
if value is not None:
|
|
84
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
85
|
+
return self.name, value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PathParamResolver(Resolver[T]):
|
|
89
|
+
"""Resolver for path parameters."""
|
|
90
|
+
|
|
91
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
92
|
+
"""Resolve the path parameter from the request context."""
|
|
93
|
+
ctx = context.get()
|
|
94
|
+
value = ctx.request.path_params.get(self.name, None)
|
|
95
|
+
if value is not None:
|
|
96
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
97
|
+
return self.name, value
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RequestBodyResolver(Resolver[T]):
|
|
101
|
+
"""Resolver for request body parameters."""
|
|
102
|
+
|
|
103
|
+
async def resolve(self) -> tuple[str, T]:
|
|
104
|
+
"""Resolve the request body from the request context."""
|
|
105
|
+
ctx = context.get()
|
|
106
|
+
body = await ctx.request.json()
|
|
107
|
+
value = msgspec.convert(body, self.typ, strict=True)
|
|
108
|
+
return self.name, value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class HeaderResolver(Resolver[T]):
|
|
112
|
+
"""Resolver for header."""
|
|
113
|
+
|
|
114
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
115
|
+
"""Resolve the header parameter from the request context."""
|
|
116
|
+
ctx = context.get()
|
|
117
|
+
headers = {
|
|
118
|
+
key.lower().replace("-", "_").replace(" ", "_"): value
|
|
119
|
+
for key, value in ctx.request.headers.items()
|
|
120
|
+
}
|
|
121
|
+
value = headers.get(self.name, None)
|
|
122
|
+
if value is not None:
|
|
123
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
124
|
+
return self.name, value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class CookieResolver(Resolver[T]):
|
|
128
|
+
"""Resolver for cookie."""
|
|
129
|
+
|
|
130
|
+
async def resolve(self) -> tuple[str, T | None]:
|
|
131
|
+
"""Resolve the cookie parameter from the request context."""
|
|
132
|
+
ctx = context.get()
|
|
133
|
+
cookies = {
|
|
134
|
+
key.lower().replace("-", "_").replace(" ", "_"): value
|
|
135
|
+
for key, value in ctx.request.cookies.items()
|
|
136
|
+
}
|
|
137
|
+
value = cookies.get(self.name, None)
|
|
138
|
+
if value is not None:
|
|
139
|
+
value = msgspec.convert(value, self.typ, strict=False)
|
|
140
|
+
return self.name, value
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Type
|
|
2
|
+
|
|
3
|
+
from starlette.exceptions import HTTPException
|
|
4
|
+
from starlette.requests import Request
|
|
5
|
+
from starlette.responses import PlainTextResponse
|
|
6
|
+
from starlette.routing import Route as StarletteRoute
|
|
7
|
+
from starlette.routing import Router as StarletteRouter
|
|
8
|
+
from starlette.types import Receive, Scope, Send
|
|
9
|
+
|
|
10
|
+
from fusion.context import Context
|
|
11
|
+
from fusion.endpoints import HttpEndpoint
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Route(StarletteRoute):
|
|
15
|
+
"""Custom route that integrates dependency injection."""
|
|
16
|
+
|
|
17
|
+
endpoint: HttpEndpoint
|
|
18
|
+
|
|
19
|
+
def __init__(self, path: str, endpoint: Type[HttpEndpoint]):
|
|
20
|
+
super().__init__(path=path, endpoint=endpoint, methods=endpoint.methods)
|
|
21
|
+
|
|
22
|
+
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
23
|
+
if scope["method"] not in self.endpoint.methods:
|
|
24
|
+
headers = {"Allow": ", ".join(self.endpoint.methods)}
|
|
25
|
+
if "app" in scope:
|
|
26
|
+
raise HTTPException(status_code=405, headers=headers)
|
|
27
|
+
else:
|
|
28
|
+
response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
|
|
29
|
+
await response(scope, receive, send)
|
|
30
|
+
else:
|
|
31
|
+
request = Request(scope, receive, send)
|
|
32
|
+
async with Context(request):
|
|
33
|
+
endpoint = (
|
|
34
|
+
await self.endpoint.instance()
|
|
35
|
+
) # initialize the endpoint with dependencies resolved
|
|
36
|
+
method = getattr(endpoint, scope["method"].lower())
|
|
37
|
+
response = await method()
|
|
38
|
+
await response(scope, receive, send)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Router(StarletteRouter):
|
|
42
|
+
...
|
|
File without changes
|