sillo-graphql 1.0.0a1__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.
- sillo_graphql-1.0.0a1/.github/workflows/ci.yml +165 -0
- sillo_graphql-1.0.0a1/.github/workflows/release.yml +90 -0
- sillo_graphql-1.0.0a1/.gitignore +15 -0
- sillo_graphql-1.0.0a1/CHANGELOG.md +124 -0
- sillo_graphql-1.0.0a1/LICENSE +27 -0
- sillo_graphql-1.0.0a1/PKG-INFO +250 -0
- sillo_graphql-1.0.0a1/README.md +215 -0
- sillo_graphql-1.0.0a1/pyproject.toml +106 -0
- sillo_graphql-1.0.0a1/sillo_graphql/__init__.py +103 -0
- sillo_graphql-1.0.0a1/sillo_graphql/context.py +220 -0
- sillo_graphql-1.0.0a1/sillo_graphql/errors.py +177 -0
- sillo_graphql-1.0.0a1/sillo_graphql/graph.py +723 -0
- sillo_graphql-1.0.0a1/sillo_graphql/ide.py +312 -0
- sillo_graphql-1.0.0a1/sillo_graphql/limits.py +403 -0
- sillo_graphql-1.0.0a1/sillo_graphql/loaders.py +323 -0
- sillo_graphql-1.0.0a1/sillo_graphql/persisted.py +242 -0
- sillo_graphql-1.0.0a1/sillo_graphql/policy.py +291 -0
- sillo_graphql-1.0.0a1/sillo_graphql/py.typed +0 -0
- sillo_graphql-1.0.0a1/sillo_graphql/resolvers.py +445 -0
- sillo_graphql-1.0.0a1/sillo_graphql/testing.py +341 -0
- sillo_graphql-1.0.0a1/sillo_graphql/tracing.py +173 -0
- sillo_graphql-1.0.0a1/sillo_graphql/transport/__init__.py +22 -0
- sillo_graphql-1.0.0a1/sillo_graphql/transport/http.py +460 -0
- sillo_graphql-1.0.0a1/sillo_graphql/transport/sse.py +86 -0
- sillo_graphql-1.0.0a1/sillo_graphql/transport/ws.py +276 -0
- sillo_graphql-1.0.0a1/tests/conftest.py +169 -0
- sillo_graphql-1.0.0a1/tests/test_context.py +144 -0
- sillo_graphql-1.0.0a1/tests/test_errors.py +96 -0
- sillo_graphql-1.0.0a1/tests/test_graph.py +512 -0
- sillo_graphql-1.0.0a1/tests/test_http.py +515 -0
- sillo_graphql-1.0.0a1/tests/test_ide.py +79 -0
- sillo_graphql-1.0.0a1/tests/test_limits.py +254 -0
- sillo_graphql-1.0.0a1/tests/test_loaders.py +292 -0
- sillo_graphql-1.0.0a1/tests/test_persisted.py +184 -0
- sillo_graphql-1.0.0a1/tests/test_policy.py +170 -0
- sillo_graphql-1.0.0a1/tests/test_resolvers.py +454 -0
- sillo_graphql-1.0.0a1/tests/test_sse.py +94 -0
- sillo_graphql-1.0.0a1/tests/test_testing.py +278 -0
- sillo_graphql-1.0.0a1/tests/test_tracing.py +165 -0
- sillo_graphql-1.0.0a1/tests/test_ws.py +379 -0
- sillo_graphql-1.0.0a1/uv.lock +974 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
# The framework this package extends, installed from the 1.0 branch rather
|
|
9
|
+
# than from PyPI: this package tracks `main`, not the last tag cut from it.
|
|
10
|
+
# (`sillo-framework>=1.0` does resolve on PyPI now that 1.0.0a1 is published,
|
|
11
|
+
# but only to that pre-release.)
|
|
12
|
+
#
|
|
13
|
+
# Installed first and explicitly, and this package then installed with
|
|
14
|
+
# `--no-deps`, so the declared floor is not re-resolved against PyPI.
|
|
15
|
+
env:
|
|
16
|
+
FRAMEWORK: "git+https://github.com/sillohq/core.git@main"
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
lint:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: "3.12"
|
|
26
|
+
|
|
27
|
+
- name: Install
|
|
28
|
+
run: |
|
|
29
|
+
python -m pip install --upgrade pip
|
|
30
|
+
pip install ruff mypy
|
|
31
|
+
pip install "$FRAMEWORK" strawberry-graphql
|
|
32
|
+
|
|
33
|
+
- name: Lint
|
|
34
|
+
run: ruff check sillo_graphql tests
|
|
35
|
+
|
|
36
|
+
- name: Format
|
|
37
|
+
run: ruff format --check sillo_graphql tests
|
|
38
|
+
|
|
39
|
+
- name: Types
|
|
40
|
+
run: mypy sillo_graphql
|
|
41
|
+
|
|
42
|
+
test:
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
strategy:
|
|
45
|
+
fail-fast: false
|
|
46
|
+
matrix:
|
|
47
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
48
|
+
|
|
49
|
+
steps:
|
|
50
|
+
- uses: actions/checkout@v4
|
|
51
|
+
- uses: actions/setup-python@v5
|
|
52
|
+
with:
|
|
53
|
+
python-version: ${{ matrix.python-version }}
|
|
54
|
+
|
|
55
|
+
- name: Install
|
|
56
|
+
run: |
|
|
57
|
+
python -m pip install --upgrade pip
|
|
58
|
+
pip install "$FRAMEWORK" strawberry-graphql
|
|
59
|
+
pip install pytest pytest-asyncio pytest-cov httpx
|
|
60
|
+
pip install -e . --no-deps
|
|
61
|
+
|
|
62
|
+
# `fail_under = 100` lives in pyproject, so a line that stops being
|
|
63
|
+
# covered fails here rather than being noticed later.
|
|
64
|
+
- name: Test
|
|
65
|
+
run: pytest --cov --cov-report=term-missing
|
|
66
|
+
|
|
67
|
+
wheel:
|
|
68
|
+
name: sillo_graphql, from a built wheel
|
|
69
|
+
runs-on: ubuntu-latest
|
|
70
|
+
steps:
|
|
71
|
+
- uses: actions/checkout@v4
|
|
72
|
+
# The newest supported interpreter. The matrix already exercises the
|
|
73
|
+
# package on every version through the editable install, so the one place
|
|
74
|
+
# worth a separate job is the wheel path on the interpreter most likely
|
|
75
|
+
# to have changed underneath site-packages.
|
|
76
|
+
- uses: actions/setup-python@v5
|
|
77
|
+
with:
|
|
78
|
+
python-version: "3.14"
|
|
79
|
+
|
|
80
|
+
- name: Build the wheel
|
|
81
|
+
run: |
|
|
82
|
+
python -m pip install --upgrade pip build
|
|
83
|
+
python -m build --wheel -o dist .
|
|
84
|
+
|
|
85
|
+
# Two distributions writing into one package directory goes wrong in both
|
|
86
|
+
# directions, so the wheel must add a top-level `sillo_graphql` and
|
|
87
|
+
# nothing else. Checked on the artefact, not on an install, because an
|
|
88
|
+
# install can hide it behind whatever is already there.
|
|
89
|
+
- name: The wheel ships sillo_graphql only
|
|
90
|
+
run: |
|
|
91
|
+
python - <<'PY'
|
|
92
|
+
import glob
|
|
93
|
+
import zipfile
|
|
94
|
+
|
|
95
|
+
names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
|
|
96
|
+
intruders = [name for name in names if name.startswith("sillo/")]
|
|
97
|
+
assert not intruders, f"wheel writes into sillo/: {intruders}"
|
|
98
|
+
strays = [n for n in names if n.endswith(".pth") or n.startswith("sillo-stubs/")]
|
|
99
|
+
assert not strays, f"alias machinery is back in the wheel: {strays}"
|
|
100
|
+
assert "sillo_graphql/py.typed" in names, "the PEP 561 marker is missing"
|
|
101
|
+
print("\n".join(sorted(names)))
|
|
102
|
+
PY
|
|
103
|
+
|
|
104
|
+
# Against a real install, not the checkout: an editable install can
|
|
105
|
+
# import what the wheel does not actually ship.
|
|
106
|
+
- name: sillo_graphql imports, and sillo.graphql does not
|
|
107
|
+
run: |
|
|
108
|
+
python -m venv /tmp/v
|
|
109
|
+
/tmp/v/bin/pip install -q "$FRAMEWORK" strawberry-graphql mypy
|
|
110
|
+
/tmp/v/bin/pip install -q --no-deps dist/*.whl
|
|
111
|
+
/tmp/v/bin/python - <<'PY'
|
|
112
|
+
import os
|
|
113
|
+
|
|
114
|
+
import sillo
|
|
115
|
+
import sillo_graphql # noqa: F401
|
|
116
|
+
|
|
117
|
+
package = os.path.dirname(sillo.__file__)
|
|
118
|
+
assert "graphql" not in os.listdir(package), "something wrote into sillo/"
|
|
119
|
+
|
|
120
|
+
# The package used to ship a .pth aliasing `sillo.graphql` onto it.
|
|
121
|
+
# That is gone, and the framework has not shipped a module of that
|
|
122
|
+
# name since 1.0, so the import must simply fail.
|
|
123
|
+
try:
|
|
124
|
+
import sillo.graphql # noqa: F401
|
|
125
|
+
except ImportError:
|
|
126
|
+
print("sillo_graphql imports; sillo.graphql does not resolve")
|
|
127
|
+
else:
|
|
128
|
+
raise AssertionError("sillo.graphql still resolves")
|
|
129
|
+
PY
|
|
130
|
+
|
|
131
|
+
# Types come from the package's own annotations plus py.typed now, not
|
|
132
|
+
# from a separate set of stubs that had to be kept in step with them.
|
|
133
|
+
- name: Type checkers resolve the package
|
|
134
|
+
run: |
|
|
135
|
+
cat > /tmp/check.py <<'PY'
|
|
136
|
+
from sillo_graphql import Graph, Limits, not_found
|
|
137
|
+
|
|
138
|
+
limits: Limits = Limits(depth=3)
|
|
139
|
+
error = not_found("gone")
|
|
140
|
+
graph: type[Graph] = Graph
|
|
141
|
+
PY
|
|
142
|
+
/tmp/v/bin/python -m mypy /tmp/check.py
|
|
143
|
+
|
|
144
|
+
# Removing either distribution must leave the other whole. The failure
|
|
145
|
+
# this guards against is an empty `sillo/` left standing with no
|
|
146
|
+
# __init__.py in it, which shadows a later reinstall.
|
|
147
|
+
#
|
|
148
|
+
# Run from /tmp, not the checkout: with the repository on sys.path these
|
|
149
|
+
# would import the source tree and prove nothing about the install.
|
|
150
|
+
- name: Uninstalling either one leaves the other intact
|
|
151
|
+
working-directory: /tmp
|
|
152
|
+
run: |
|
|
153
|
+
/tmp/v/bin/pip uninstall -y -q sillo-graphql
|
|
154
|
+
/tmp/v/bin/python -c "import sillo; print('framework survives')"
|
|
155
|
+
/tmp/v/bin/pip install -q --no-deps $GITHUB_WORKSPACE/dist/*.whl
|
|
156
|
+
/tmp/v/bin/pip uninstall -y -q sillo-framework
|
|
157
|
+
/tmp/v/bin/python - <<'PY'
|
|
158
|
+
import importlib.util
|
|
159
|
+
|
|
160
|
+
# `sillo_graphql` needs the framework to import, so it is not asked
|
|
161
|
+
# to. What must be true is that nothing of `sillo` is left behind:
|
|
162
|
+
# a directory with no __init__.py in it would shadow a reinstall.
|
|
163
|
+
assert importlib.util.find_spec("sillo") is None, "headless sillo/ left behind"
|
|
164
|
+
print("no headless sillo/")
|
|
165
|
+
PY
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'sillo-graphql-v*'
|
|
7
|
+
|
|
8
|
+
# The framework this package extends. `sillo-framework>=1.0` now resolves on
|
|
9
|
+
# PyPI -- 1.0.0a1 is published -- but a pre-release only, and this package
|
|
10
|
+
# tracks the 1.0 branch rather than the last tag cut from it. Install the
|
|
11
|
+
# framework from `main`, then this package with `--no-deps`.
|
|
12
|
+
env:
|
|
13
|
+
FRAMEWORK: "git+https://github.com/sillohq/core.git@main"
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
publish:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
permissions:
|
|
19
|
+
# Trusted publishing, used when PYPI_TOKEN is not set.
|
|
20
|
+
id-token: write
|
|
21
|
+
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- name: Install uv
|
|
26
|
+
uses: astral-sh/setup-uv@v5
|
|
27
|
+
|
|
28
|
+
- uses: actions/setup-python@v5
|
|
29
|
+
with:
|
|
30
|
+
python-version: "3.12"
|
|
31
|
+
|
|
32
|
+
- name: Read the tag
|
|
33
|
+
id: version
|
|
34
|
+
run: echo "version=${GITHUB_REF_NAME#sillo-graphql-v}" >> "$GITHUB_OUTPUT"
|
|
35
|
+
|
|
36
|
+
# Three places carry the version; a release that reports one it is not is
|
|
37
|
+
# worse than one that fails to build.
|
|
38
|
+
- name: The tag, pyproject.toml and __version__ must agree
|
|
39
|
+
run: |
|
|
40
|
+
tag='${{ steps.version.outputs.version }}'
|
|
41
|
+
project=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
|
|
42
|
+
module=$(grep -m1 '^__version__' sillo_graphql/__init__.py | cut -d'"' -f2)
|
|
43
|
+
|
|
44
|
+
echo "tag=$tag pyproject=$project module=$module"
|
|
45
|
+
|
|
46
|
+
if [ "$tag" != "$project" ] || [ "$tag" != "$module" ]; then
|
|
47
|
+
echo "These disagree. Bump all three and tag again."
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
- name: Install
|
|
52
|
+
run: |
|
|
53
|
+
uv venv
|
|
54
|
+
uv pip install "$FRAMEWORK" strawberry-graphql
|
|
55
|
+
uv pip install pytest pytest-asyncio pytest-cov httpx
|
|
56
|
+
uv pip install -e . --no-deps
|
|
57
|
+
|
|
58
|
+
- name: Test
|
|
59
|
+
run: uv run pytest -q
|
|
60
|
+
|
|
61
|
+
- name: Build
|
|
62
|
+
run: uv build
|
|
63
|
+
|
|
64
|
+
# The collision this layout exists to avoid: two distributions writing
|
|
65
|
+
# into one package directory. Checked on the artefact, not on an install.
|
|
66
|
+
- name: The wheel must write nothing into sillo/
|
|
67
|
+
run: |
|
|
68
|
+
python - <<'PY'
|
|
69
|
+
import glob
|
|
70
|
+
import zipfile
|
|
71
|
+
|
|
72
|
+
names = zipfile.ZipFile(glob.glob("dist/*.whl")[0]).namelist()
|
|
73
|
+
intruders = [n for n in names if n.startswith("sillo/")]
|
|
74
|
+
assert not intruders, f"wheel writes into sillo/: {intruders}"
|
|
75
|
+
assert "sillo_graphql/__init__.py" in names, "the package is missing"
|
|
76
|
+
assert "sillo_graphql/py.typed" in names, "the PEP 561 marker is missing"
|
|
77
|
+
strays = [n for n in names if n.endswith(".pth") or n.startswith("sillo-stubs/")]
|
|
78
|
+
assert not strays, f"alias machinery is back in the wheel: {strays}"
|
|
79
|
+
print("wheel ships sillo_graphql only, and stays out of sillo/")
|
|
80
|
+
PY
|
|
81
|
+
|
|
82
|
+
- name: Publish
|
|
83
|
+
env:
|
|
84
|
+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
|
85
|
+
run: |
|
|
86
|
+
if [ -n "$UV_PUBLISH_TOKEN" ]; then
|
|
87
|
+
uv publish
|
|
88
|
+
else
|
|
89
|
+
uv publish --trusted-publishing always
|
|
90
|
+
fi
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [1.0.0a1] - 2026-09-13
|
|
10
|
+
|
|
11
|
+
First alpha, released alongside `sillo-framework` 1.0.0a1. Install with
|
|
12
|
+
`pip install --pre sillo-graphql==1.0.0a1`.
|
|
13
|
+
|
|
14
|
+
An alpha: this is what 1.0 is expected to look like, but the API is not frozen
|
|
15
|
+
yet and may still change before `1.0.0`.
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
|
|
19
|
+
- **The `sillo.graphql` import alias.** `sillo_graphql` is now the only import
|
|
20
|
+
path:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from sillo_graphql import Graph, field # was: from sillo.graphql import ...
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The alias was a meta-path finder in `_sillo_graphql_bootstrap.py`, a
|
|
27
|
+
`sillo_graphql.pth` registering it at interpreter startup, and PEP 561 stubs
|
|
28
|
+
under `sillo-stubs/` to serve type checkers, which never run import hooks.
|
|
29
|
+
That is three mechanisms, a `.pth` executing on every interpreter start in
|
|
30
|
+
every environment the package is installed in, and a second set of type
|
|
31
|
+
declarations to keep in step with the real ones — so that an import could
|
|
32
|
+
read as part of the framework. A plain top-level package needs none of it.
|
|
33
|
+
|
|
34
|
+
`_sillo_graphql_bootstrap.py`, `sillo_graphql.pth` and `sillo-stubs/` are
|
|
35
|
+
gone, along with the `force-include` blocks that shipped them.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`sillo_graphql/py.typed`.** The package claimed `Typing :: Typed` but
|
|
40
|
+
shipped no PEP 561 marker of its own — type checkers were served entirely by
|
|
41
|
+
`sillo-stubs/`. Removing the stubs without this would have silently made the
|
|
42
|
+
package untyped for consumers. Its inline annotations are now the single
|
|
43
|
+
source of truth.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **Resolver `Depend(...)` stopped resolving on newer framework builds.** Sillo
|
|
48
|
+
v1's `get_dependant` treats a callable's first parameter as the context slot
|
|
49
|
+
and only scans the parameters after it for `Depend` markers. The stand-in
|
|
50
|
+
signature this package builds for a resolver's dependencies had no such slot,
|
|
51
|
+
so its first dependency was skipped and injected as `None`. It now carries a
|
|
52
|
+
leading context parameter. A resolver dependency that took no arguments must
|
|
53
|
+
now take a leading one (`def get_db(_): ...`), matching the framework's rule
|
|
54
|
+
for every dependency.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- A `release` workflow: pushing a `sillo-graphql-v<version>` tag checks the
|
|
59
|
+
three version strings agree, runs the suite, builds, verifies the wheel
|
|
60
|
+
carries the alias `.pth` and the PEP 561 stubs and writes nothing into
|
|
61
|
+
`sillo/`, and publishes (trusted publishing, or `PYPI_TOKEN`).
|
|
62
|
+
|
|
63
|
+
## [0.1.0]
|
|
64
|
+
|
|
65
|
+
First release. Extracted from the framework's `sillo.graphql` module, and
|
|
66
|
+
rebuilt around it rather than moved.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- `Graph`, built and then mounted, matching how every other subsystem attaches
|
|
71
|
+
to an application. Mounts on a `SilloApp` or a `Router`.
|
|
72
|
+
- `field`, `mutation` and `subscription` decorators that adapt `sillo`'s
|
|
73
|
+
handler convention onto Strawberry resolvers: `ctx` and `Depend` parameters
|
|
74
|
+
are injected and stripped from the schema; everything else is a GraphQL
|
|
75
|
+
argument. Dependencies are resolved through the framework's own solver, so
|
|
76
|
+
one operation shares one dependency cache.
|
|
77
|
+
- `GraphContext`, a typed context carrying the connection, a response handle
|
|
78
|
+
for setting status, headers and cookies from a resolver, the loader registry
|
|
79
|
+
and the authenticated user. Also a `Mapping`, so `info.context["ctx"]` keeps
|
|
80
|
+
working.
|
|
81
|
+
- Static cost analysis before execution: depth, aliases, breadth, document
|
|
82
|
+
tokens, and a weighted cost with list multipliers derived from `first`,
|
|
83
|
+
`last`, `limit` and friends. `@field(cost=...)` prices a field.
|
|
84
|
+
- Error policy: free builders (`not_found`, `forbidden`, `unauthenticated`,
|
|
85
|
+
`bad_input`, `conflict`, `too_many_requests`, `internal`) with stable
|
|
86
|
+
`extensions.code`, masking of unexpected exceptions, `@graph.on_error`
|
|
87
|
+
mapping, and a correlation id on every error.
|
|
88
|
+
- Request-scoped `DataLoader` batching via `@graph.loader`, with cache
|
|
89
|
+
priming, `load_many`, batch-size chunking and per-key error results.
|
|
90
|
+
- Subscriptions over `graphql-transport-ws`, with an initialisation timeout,
|
|
91
|
+
ping/pong, duplicate-id detection and cancellation of every operation when
|
|
92
|
+
the socket closes. `@graph.on_connect` authenticates from the
|
|
93
|
+
`connection_init` payload.
|
|
94
|
+
- Subscriptions over `text/event-stream` (`sse=True`) for clients that cannot
|
|
95
|
+
hold a socket open.
|
|
96
|
+
- GraphQL-over-HTTP: content negotiation between
|
|
97
|
+
`application/graphql-response+json` and legacy `application/json`, correct
|
|
98
|
+
status codes, `GET` queries with mutations refused, `application/graphql`
|
|
99
|
+
bodies, form bodies, capped sequential batching, and body size limits.
|
|
100
|
+
- File uploads per the GraphQL multipart request spec, with per-file, per-count
|
|
101
|
+
and per-request size limits and a content-type allow-list. Off by default.
|
|
102
|
+
- Persisted operations: APQ with hash verification, and a trusted-document
|
|
103
|
+
manifest that refuses everything not in it.
|
|
104
|
+
- A bundled explorer with no external requests, so it works offline and under
|
|
105
|
+
a strict CSP. GraphiQL from a CDN remains available with `IDE(assets="cdn")`.
|
|
106
|
+
- `Metrics`, `OperationLog` and an OpenTelemetry hook, all through
|
|
107
|
+
`@graph.on_operation`.
|
|
108
|
+
- `GraphClient` and `SubscriptionStream` test helpers.
|
|
109
|
+
- `sillo.graphql` as an import alias for `sillo_graphql`, via a `.pth`-loaded
|
|
110
|
+
meta-path finder and PEP 561 partial stubs. Nothing is written into the
|
|
111
|
+
framework's package directory, and the alias refuses rather than shadows a
|
|
112
|
+
framework that still ships its own `sillo.graphql`.
|
|
113
|
+
|
|
114
|
+
### Changed from the framework's `sillo.graphql`
|
|
115
|
+
|
|
116
|
+
- The explorer is **off** by default, and introspection with it.
|
|
117
|
+
- Resolver exceptions are **masked** by default.
|
|
118
|
+
- Depth and cost limits are **enforced** by default.
|
|
119
|
+
- A request with no document is a 400, not a 500.
|
|
120
|
+
- The endpoint is excluded from the OpenAPI document, which described it
|
|
121
|
+
incorrectly.
|
|
122
|
+
|
|
123
|
+
[Unreleased]: https://github.com/sillohq/graphql/compare/v0.1.0...HEAD
|
|
124
|
+
[0.1.0]: https://github.com/sillohq/graphql/releases/tag/v0.1.0
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present, sillo-Labs OSS.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without modification,
|
|
7
|
+
are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
15
|
+
contributors may be used to endorse or promote products derived from
|
|
16
|
+
this software without specific prior written permission.
|
|
17
|
+
|
|
18
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
19
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
20
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
21
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
22
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
23
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
24
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
25
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
26
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
27
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sillo-graphql
|
|
3
|
+
Version: 1.0.0a1
|
|
4
|
+
Summary: Production GraphQL for Sillo — sillo-style resolvers with DI, subscriptions, cost limits, persisted operations and batching.
|
|
5
|
+
Project-URL: Homepage, https://sillo.build
|
|
6
|
+
Project-URL: Documentation, https://docs.sillo.build/packages/graphql/
|
|
7
|
+
Project-URL: Source, https://github.com/sillohq/graphql
|
|
8
|
+
Project-URL: Changelog, https://github.com/sillohq/graphql/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Chidebele Dunamis <techwithdunamix@gmail.com>
|
|
10
|
+
License-Expression: BSD-3-Clause
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: api,asgi,dataloader,graphql,persisted-queries,sillo,strawberry,subscriptions
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: AsyncIO
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: BSD License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: sillo-framework>=1.0
|
|
26
|
+
Requires-Dist: strawberry-graphql>=0.219.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# sillo-graphql
|
|
37
|
+
|
|
38
|
+
Production GraphQL for [Sillo](https://sillo.build). Installs as
|
|
39
|
+
`sillo-graphql`, imports as `sillo_graphql`.
|
|
40
|
+
|
|
41
|
+
Strawberry owns the schema. This package owns everything around it — the
|
|
42
|
+
transports, the safety, and the observability.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install sillo-graphql
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import strawberry
|
|
50
|
+
from sillo import Depend, HttpContext, SilloApp
|
|
51
|
+
from sillo_graphql import Graph, Limits, field
|
|
52
|
+
|
|
53
|
+
@strawberry.type
|
|
54
|
+
class Query:
|
|
55
|
+
@field
|
|
56
|
+
async def me(ctx: HttpContext, db=Depend(get_db)) -> User:
|
|
57
|
+
return await db.users.get(ctx.user.id)
|
|
58
|
+
|
|
59
|
+
app = SilloApp()
|
|
60
|
+
Graph(strawberry.Schema(query=Query), limits=Limits(depth=8)).mount(app)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Resolvers that read like handlers
|
|
64
|
+
|
|
65
|
+
A `sillo` route handler takes the context first and declares what else it
|
|
66
|
+
needs. So does a resolver here:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
@field
|
|
70
|
+
async def posts(ctx: HttpContext, db=Depend(get_db), limit: int = 10) -> list[Post]:
|
|
71
|
+
return await db.posts.recent(limit)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
One rule: **`ctx` and anything defaulted to `Depend` are injected and never
|
|
75
|
+
appear in the schema; every other parameter is a GraphQL argument.** So this
|
|
76
|
+
field takes exactly one argument, `limit`.
|
|
77
|
+
|
|
78
|
+
Dependencies are resolved by the framework's own solver, with the framework's
|
|
79
|
+
own pre-flattened execution plan — two resolvers in one operation that both
|
|
80
|
+
ask for `Depend(get_db)` are handed the same session.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
graph = Graph(
|
|
86
|
+
schema,
|
|
87
|
+
path="/graphql",
|
|
88
|
+
ide=False, # explorer, off by default
|
|
89
|
+
introspection=False, # off by default
|
|
90
|
+
subscriptions=True,
|
|
91
|
+
auth=Bearer(), # the route's auth= gate
|
|
92
|
+
limits=Limits(depth=10, cost=1_000, aliases=15),
|
|
93
|
+
errors=ErrorPolicy(mask=True),
|
|
94
|
+
transport=Transport(get_queries=True, batch=10),
|
|
95
|
+
uploads=Uploads(enabled=True, max_size="10MB"),
|
|
96
|
+
persisted=Persisted(apq=True, trusted="operations.json"),
|
|
97
|
+
)
|
|
98
|
+
graph.mount(app)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Common knobs are keyword arguments; the deeper ones are policy objects, the
|
|
102
|
+
same split the framework makes between arguments on `SilloApp` and objects
|
|
103
|
+
like `CSRFConfig`.
|
|
104
|
+
|
|
105
|
+
Every default is chosen for a public endpoint.
|
|
106
|
+
|
|
107
|
+
## What it does
|
|
108
|
+
|
|
109
|
+
### Cost limits, enforced before execution
|
|
110
|
+
|
|
111
|
+
Depth, aliases, breadth and document size, plus a weighted cost that
|
|
112
|
+
understands lists — a field returning a list multiplies everything under it, by
|
|
113
|
+
the page size the caller asked for when that is knowable.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
@field(cost=25)
|
|
117
|
+
async def search(ctx: HttpContext, term: str) -> list[Hit]: ...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
An operation over budget is refused with `OPERATION_TOO_COMPLEX` and the limit
|
|
121
|
+
it passed, before a single resolver runs. Refusing afterwards would mean having
|
|
122
|
+
already done the work.
|
|
123
|
+
|
|
124
|
+
### Errors that say what happened, and no more
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from sillo_graphql import forbidden, not_found
|
|
128
|
+
|
|
129
|
+
@field
|
|
130
|
+
async def post(ctx: HttpContext, id: int) -> Post:
|
|
131
|
+
found = await Post.objects.get_or_none(id=id)
|
|
132
|
+
if found is None:
|
|
133
|
+
raise not_found("No such post") # extensions.code == "NOT_FOUND"
|
|
134
|
+
return found
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Free builders, like the framework's `json()` and `text()`. Errors raised this
|
|
138
|
+
way are deliberate and reach the client. An exception that escapes a resolver
|
|
139
|
+
is masked, logged with its traceback, and reported as `INTERNAL_SERVER_ERROR` —
|
|
140
|
+
because what it said may name a host, a table or a credential.
|
|
141
|
+
|
|
142
|
+
Map your own:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
@graph.on_error(RecordNotFound)
|
|
146
|
+
def _(exc): return not_found(str(exc))
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Batching, so a graph query is not a table scan per node
|
|
150
|
+
|
|
151
|
+
```python
|
|
152
|
+
@graph.loader
|
|
153
|
+
async def load_author(keys: list[int]) -> list[User]:
|
|
154
|
+
rows = await User.objects.filter(id__in=keys).all()
|
|
155
|
+
return align(rows, keys)
|
|
156
|
+
|
|
157
|
+
@field
|
|
158
|
+
async def author(ctx: HttpContext, root: Post) -> User:
|
|
159
|
+
return await load_author(root.author_id)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Keys asked for by sibling fields in the same tick become one call. State is per
|
|
163
|
+
operation, so two concurrent requests never share a cache.
|
|
164
|
+
|
|
165
|
+
### Subscriptions that exist
|
|
166
|
+
|
|
167
|
+
`graphql-transport-ws` over the framework's own WebSocket layer, with an
|
|
168
|
+
initialisation timeout, ping/pong keepalive, and cancellation in a `finally` so
|
|
169
|
+
an operation cannot outlive its socket. Authentication belongs in
|
|
170
|
+
`connection_init`, because a browser cannot set headers on a WebSocket
|
|
171
|
+
handshake:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
@graph.on_connect
|
|
175
|
+
async def authenticate(socket, params):
|
|
176
|
+
token = params.get("authorization")
|
|
177
|
+
if not token:
|
|
178
|
+
raise unauthenticated("A token is required")
|
|
179
|
+
return {"user": await user_for(token)}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The same subscriptions are available over `text/event-stream` with `sse=True`,
|
|
183
|
+
for clients that cannot hold a socket open.
|
|
184
|
+
|
|
185
|
+
### The rest of the HTTP surface
|
|
186
|
+
|
|
187
|
+
Batched operations (capped, sequential), `GET` for queries with mutations
|
|
188
|
+
refused, `application/graphql` bodies, file uploads per the multipart request
|
|
189
|
+
spec, and content negotiation between `application/graphql-response+json` — the
|
|
190
|
+
spec's status codes — and legacy `application/json`, which stays always-200 for
|
|
191
|
+
the clients that expect it.
|
|
192
|
+
|
|
193
|
+
### Persisted operations
|
|
194
|
+
|
|
195
|
+
APQ saves bandwidth. A trusted-document manifest is the one that matters: with
|
|
196
|
+
`Persisted(trusted="operations.json")` the endpoint executes nothing else, so
|
|
197
|
+
the workload becomes finite and known.
|
|
198
|
+
|
|
199
|
+
### Knowing what it is doing
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
graph.on_operation(OperationLog(slower_than=0.5))
|
|
203
|
+
|
|
204
|
+
metrics = Metrics()
|
|
205
|
+
graph.on_operation(metrics)
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Per operation, not per path: `p99` on `POST /graphql` averages over work that
|
|
209
|
+
has nothing in common.
|
|
210
|
+
|
|
211
|
+
## Testing
|
|
212
|
+
|
|
213
|
+
```python
|
|
214
|
+
from sillo_graphql.testing import GraphClient
|
|
215
|
+
|
|
216
|
+
def test_me():
|
|
217
|
+
with GraphClient(app) as gql:
|
|
218
|
+
result = gql.query("{ me { email } }")
|
|
219
|
+
assert result.ok
|
|
220
|
+
assert result["me"]["email"] == "a@b.c"
|
|
221
|
+
|
|
222
|
+
async def test_prices():
|
|
223
|
+
async with GraphClient(app).subscribe(PRICES, symbol="ACME") as stream:
|
|
224
|
+
assert (await stream.next())["prices"]["last"] == 10
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Migrating from `sillo.graphql` in the framework
|
|
228
|
+
|
|
229
|
+
| before | now |
|
|
230
|
+
| --- | --- |
|
|
231
|
+
| `GraphQL(app, schema, path=, graphiql=True)` | `Graph(schema, path=, ide=False).mount(app)` |
|
|
232
|
+
| `info.context["ctx"]` | a `ctx: HttpContext` parameter |
|
|
233
|
+
| `self` / `info` resolver convention | `ctx` first, like every handler |
|
|
234
|
+
| — | `Depend(...)`, `@graph.loader`, `@graph.on_error` |
|
|
235
|
+
| errors leaked, IDE on, no limits | masked, IDE off, depth and cost enforced |
|
|
236
|
+
|
|
237
|
+
`info.context["ctx"]` still works — the context is a `Mapping` — so a schema
|
|
238
|
+
can migrate one resolver at a time.
|
|
239
|
+
|
|
240
|
+
## Requirements
|
|
241
|
+
|
|
242
|
+
Python 3.10+, `sillo-framework` 1.0 or newer, `strawberry-graphql`.
|
|
243
|
+
|
|
244
|
+
1.0 is the floor because the resolver bridge is built on the context-handler
|
|
245
|
+
API. Versions before 1.0 also shipped a `sillo.graphql` of their own, which is
|
|
246
|
+
unrelated to this package and is what the table above migrates from.
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
BSD-3-Clause.
|