sw-serverless 10.2.3__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.
- sw_serverless-10.2.3/PKG-INFO +58 -0
- sw_serverless-10.2.3/README.md +46 -0
- sw_serverless-10.2.3/pyproject.toml +20 -0
- sw_serverless-10.2.3/setup.cfg +4 -0
- sw_serverless-10.2.3/src/sw_serverless/__init__.py +28 -0
- sw_serverless-10.2.3/src/sw_serverless/_adapter.py +180 -0
- sw_serverless-10.2.3/src/sw_serverless/_hpack.py +186 -0
- sw_serverless-10.2.3/src/sw_serverless/_http2.py +253 -0
- sw_serverless-10.2.3/src/sw_serverless/_huffman.py +37 -0
- sw_serverless-10.2.3/src/sw_serverless/_runner.py +565 -0
- sw_serverless-10.2.3/src/sw_serverless/_types.py +124 -0
- sw_serverless-10.2.3/src/sw_serverless/_wire.py +242 -0
- sw_serverless-10.2.3/src/sw_serverless.egg-info/PKG-INFO +58 -0
- sw_serverless-10.2.3/src/sw_serverless.egg-info/SOURCES.txt +17 -0
- sw_serverless-10.2.3/src/sw_serverless.egg-info/dependency_links.txt +1 -0
- sw_serverless-10.2.3/src/sw_serverless.egg-info/top_level.txt +1 -0
- sw_serverless-10.2.3/tests/test_adapter.py +124 -0
- sw_serverless-10.2.3/tests/test_memory_limit.py +79 -0
- sw_serverless-10.2.3/tests/test_wire.py +62 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sw-serverless
|
|
3
|
+
Version: 10.2.3
|
|
4
|
+
Summary: Write SW-Serverless adapters in Python: settings, commands, and the host protocol, with no dependencies.
|
|
5
|
+
Author: Simplify9
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/simplify9/SW-Serverless
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Operating System :: POSIX
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# sw-serverless
|
|
14
|
+
|
|
15
|
+
Write SW-Serverless adapters in Python 3.12 or later. The package has no dependencies: it speaks the
|
|
16
|
+
host's protocol — gRPC over a Unix socket — with the standard library alone, so it vendors into an
|
|
17
|
+
adapter package as plain files, for any platform.
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
import sw_serverless as sw
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Greeter:
|
|
24
|
+
def __init__(self):
|
|
25
|
+
sw.expect("Greeting", "Hello", description="What to say")
|
|
26
|
+
sw.expect("ApiKey", secret=True)
|
|
27
|
+
|
|
28
|
+
@sw.command(description="Greets someone")
|
|
29
|
+
def greet(self, name: str) -> str:
|
|
30
|
+
return f"{sw.value_of('Greeting')}, {name}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == "__main__":
|
|
34
|
+
sw.run(Greeter)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- **Settings** are declared with `sw.expect(name, default=None, *, required, secret, description, type)`
|
|
38
|
+
and read with `sw.value_of(name)`: the call's own properties, then the values the adapter was
|
|
39
|
+
started with, then the default.
|
|
40
|
+
- **Commands** are methods marked `@sw.command(name, description=...)`. A command takes at most one
|
|
41
|
+
argument. A `str` argument or result is raw text, `bytes` are passed as they are, and anything else
|
|
42
|
+
is JSON; a dataclass is read from and written as a JSON object. Commands may be sync or async; sync
|
|
43
|
+
ones run on a worker thread.
|
|
44
|
+
- **Errors** raised by a command reach the caller with their type and message. Raise
|
|
45
|
+
`sw.AdapterError(message, type="Acme.Rejected")` to choose the type.
|
|
46
|
+
- **Resident adapters** have a `start` method and run until stopped, with optional `stop`, `status`
|
|
47
|
+
(returns `connected`, `state`, `details`…) and `reset(session_id)`. In a command,
|
|
48
|
+
`sw.context()` publishes events (`await ctx.publish(...)`), keeps small state
|
|
49
|
+
(`get_state`/`set_state`/`delete_state`) and records metrics.
|
|
50
|
+
- **Logging** through Python's `logging` reaches the host.
|
|
51
|
+
- `python main.py --describe` prints what the adapter is; `sw-serverless build` writes it into the
|
|
52
|
+
manifest.
|
|
53
|
+
|
|
54
|
+
An application with a contract of its own declares it with `@sw.implements("orders", 1, "processor")`
|
|
55
|
+
on the adapter class, and can give its adapter authors base classes that do it for them.
|
|
56
|
+
`sw-serverless init --lang python` starts an adapter.
|
|
57
|
+
|
|
58
|
+
Tests: `PYTHONPATH=src python -m unittest discover -s tests`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# sw-serverless
|
|
2
|
+
|
|
3
|
+
Write SW-Serverless adapters in Python 3.12 or later. The package has no dependencies: it speaks the
|
|
4
|
+
host's protocol — gRPC over a Unix socket — with the standard library alone, so it vendors into an
|
|
5
|
+
adapter package as plain files, for any platform.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import sw_serverless as sw
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Greeter:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
sw.expect("Greeting", "Hello", description="What to say")
|
|
14
|
+
sw.expect("ApiKey", secret=True)
|
|
15
|
+
|
|
16
|
+
@sw.command(description="Greets someone")
|
|
17
|
+
def greet(self, name: str) -> str:
|
|
18
|
+
return f"{sw.value_of('Greeting')}, {name}"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == "__main__":
|
|
22
|
+
sw.run(Greeter)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **Settings** are declared with `sw.expect(name, default=None, *, required, secret, description, type)`
|
|
26
|
+
and read with `sw.value_of(name)`: the call's own properties, then the values the adapter was
|
|
27
|
+
started with, then the default.
|
|
28
|
+
- **Commands** are methods marked `@sw.command(name, description=...)`. A command takes at most one
|
|
29
|
+
argument. A `str` argument or result is raw text, `bytes` are passed as they are, and anything else
|
|
30
|
+
is JSON; a dataclass is read from and written as a JSON object. Commands may be sync or async; sync
|
|
31
|
+
ones run on a worker thread.
|
|
32
|
+
- **Errors** raised by a command reach the caller with their type and message. Raise
|
|
33
|
+
`sw.AdapterError(message, type="Acme.Rejected")` to choose the type.
|
|
34
|
+
- **Resident adapters** have a `start` method and run until stopped, with optional `stop`, `status`
|
|
35
|
+
(returns `connected`, `state`, `details`…) and `reset(session_id)`. In a command,
|
|
36
|
+
`sw.context()` publishes events (`await ctx.publish(...)`), keeps small state
|
|
37
|
+
(`get_state`/`set_state`/`delete_state`) and records metrics.
|
|
38
|
+
- **Logging** through Python's `logging` reaches the host.
|
|
39
|
+
- `python main.py --describe` prints what the adapter is; `sw-serverless build` writes it into the
|
|
40
|
+
manifest.
|
|
41
|
+
|
|
42
|
+
An application with a contract of its own declares it with `@sw.implements("orders", 1, "processor")`
|
|
43
|
+
on the adapter class, and can give its adapter authors base classes that do it for them.
|
|
44
|
+
`sw-serverless init --lang python` starts an adapter.
|
|
45
|
+
|
|
46
|
+
Tests: `PYTHONPATH=src python -m unittest discover -s tests`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sw-serverless"
|
|
7
|
+
version = "10.2.3"
|
|
8
|
+
description = "Write SW-Serverless adapters in Python: settings, commands, and the host protocol, with no dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Simplify9" }]
|
|
13
|
+
classifiers = ["Programming Language :: Python :: 3", "Operating System :: POSIX"]
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.urls]
|
|
17
|
+
Repository = "https://github.com/simplify9/SW-Serverless"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Write SW-Serverless adapters in Python.
|
|
2
|
+
|
|
3
|
+
import sw_serverless as sw
|
|
4
|
+
|
|
5
|
+
class Greeter:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
sw.expect("Greeting", "Hello", description="What to say")
|
|
8
|
+
|
|
9
|
+
@sw.command(description="Greets someone")
|
|
10
|
+
def greet(self, name: str) -> str:
|
|
11
|
+
return f"{sw.value_of('Greeting')}, {name}"
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
sw.run(Greeter)
|
|
15
|
+
|
|
16
|
+
``python adapter.py --describe`` prints what it is; the host runs it otherwise. An adapter with a
|
|
17
|
+
``start`` hook is resident: it runs until stopped, with ``stop``, ``status`` and ``reset`` hooks.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from ._adapter import command, declared_settings, expect, implements, startup_values, value_of
|
|
21
|
+
from ._runner import SDK_VERSION, AdapterError, Context, context, describe, run
|
|
22
|
+
|
|
23
|
+
__version__ = SDK_VERSION
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"AdapterError", "Context", "SDK_VERSION", "command", "context", "declared_settings", "describe",
|
|
27
|
+
"expect", "implements", "run", "startup_values", "value_of",
|
|
28
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Declaring an adapter: its settings, its commands, its kinds and contracts.
|
|
2
|
+
|
|
3
|
+
Settings are declared once, in code, with :func:`expect` — the Python form of the .NET SDK's
|
|
4
|
+
``Runner.Expect`` — and reach the manifest through ``--describe`` and the host through ``Hello``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextvars
|
|
8
|
+
import inspect
|
|
9
|
+
import typing
|
|
10
|
+
|
|
11
|
+
from . import _types
|
|
12
|
+
|
|
13
|
+
SETTING_TYPES = ("text", "multiline", "number", "boolean", "select", "json")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Setting:
|
|
17
|
+
def __init__(self, name, default, required, secret, description, type):
|
|
18
|
+
self.name = name
|
|
19
|
+
self.default = default
|
|
20
|
+
self.required = required
|
|
21
|
+
self.secret = secret
|
|
22
|
+
self.description = description
|
|
23
|
+
self.type = type
|
|
24
|
+
|
|
25
|
+
def describe(self):
|
|
26
|
+
return {"name": self.name, "description": self.description, "type": self.type,
|
|
27
|
+
"required": self.required, "secret": self.secret, "default": self.default}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_settings = {}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def expect(name, default=None, *, required=None, secret=False, description=None, type="text"):
|
|
34
|
+
"""Declares a setting the adapter reads.
|
|
35
|
+
|
|
36
|
+
Required unless it has a default or ``required=False`` says otherwise. A secret is masked
|
|
37
|
+
wherever a host application shows it. Declaring the same name again replaces it.
|
|
38
|
+
"""
|
|
39
|
+
if not name or not isinstance(name, str):
|
|
40
|
+
raise ValueError("a setting needs a name")
|
|
41
|
+
if type not in SETTING_TYPES:
|
|
42
|
+
raise ValueError(f"setting type must be one of {', '.join(SETTING_TYPES)}")
|
|
43
|
+
if required is None:
|
|
44
|
+
required = default is None
|
|
45
|
+
_settings[name] = Setting(name, None if default is None else str(default), bool(required), bool(secret),
|
|
46
|
+
description, type)
|
|
47
|
+
return name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def declared_settings():
|
|
51
|
+
return list(_settings.values())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# The values a call sees: this invocation's properties over the process's startup values.
|
|
55
|
+
_startup_values = {}
|
|
56
|
+
_call_values = contextvars.ContextVar("sw_call_values", default=None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def value_of(name, default=None):
|
|
60
|
+
"""A setting's value for the current call: the call's own properties, then the values the
|
|
61
|
+
adapter was started with, then the declared default, then ``default``."""
|
|
62
|
+
call = _call_values.get()
|
|
63
|
+
if call and name in call:
|
|
64
|
+
return call[name]
|
|
65
|
+
if name in _startup_values:
|
|
66
|
+
return _startup_values[name]
|
|
67
|
+
declared = _settings.get(name)
|
|
68
|
+
if declared is not None and declared.default is not None:
|
|
69
|
+
return declared.default
|
|
70
|
+
return default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def startup_values():
|
|
74
|
+
return dict(_startup_values)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def command(name=None, *, description=None):
|
|
78
|
+
"""Marks a method as a command the host can call, under ``name`` or the method's own name."""
|
|
79
|
+
|
|
80
|
+
def mark(fn):
|
|
81
|
+
fn.__sw_command__ = {"name": name or fn.__name__, "description": description}
|
|
82
|
+
return fn
|
|
83
|
+
|
|
84
|
+
if callable(name): # used bare: @command
|
|
85
|
+
fn, name = name, None
|
|
86
|
+
return mark(fn)
|
|
87
|
+
return mark
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Command:
|
|
91
|
+
def __init__(self, name, method, description):
|
|
92
|
+
self.name = name
|
|
93
|
+
self.method = method
|
|
94
|
+
self.description = description
|
|
95
|
+
signature = inspect.signature(method)
|
|
96
|
+
parameters = [p for p in signature.parameters.values()
|
|
97
|
+
if p.name != "self" and p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
|
|
98
|
+
if len(parameters) > 1:
|
|
99
|
+
raise TypeError(f"command {name} takes {len(parameters)} arguments; a command takes at most one")
|
|
100
|
+
try:
|
|
101
|
+
hints = typing.get_type_hints(method)
|
|
102
|
+
except Exception:
|
|
103
|
+
hints = {}
|
|
104
|
+
self.takes_argument = bool(parameters)
|
|
105
|
+
self.argument_type = hints.get(parameters[0].name, _types._EMPTY) if parameters else None
|
|
106
|
+
returns = hints.get("return", signature.return_annotation)
|
|
107
|
+
self.returns_value = returns not in (None, type(None))
|
|
108
|
+
self.result_type = returns
|
|
109
|
+
|
|
110
|
+
def info(self):
|
|
111
|
+
input_schema = _types.schema(self.argument_type) if self.takes_argument else None
|
|
112
|
+
output_schema = _types.schema(self.result_type) if self.returns_value else None
|
|
113
|
+
return {"name": self.name, "description": self.description, "takes_argument": self.takes_argument,
|
|
114
|
+
"returns_value": self.returns_value, "input_schema": input_schema, "output_schema": output_schema,
|
|
115
|
+
"parameter_type": _type_name(self.argument_type) if self.takes_argument else ""}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _type_name(tp):
|
|
119
|
+
if tp in (_types._EMPTY, None):
|
|
120
|
+
return "object"
|
|
121
|
+
return getattr(tp, "__name__", str(tp))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def implements(contract, version, *kinds):
|
|
125
|
+
"""Declares, on an adapter class, a contract it implements and the kinds of it it is.
|
|
126
|
+
|
|
127
|
+
@sw.implements("orders", 1, "processor")
|
|
128
|
+
class Orders: ...
|
|
129
|
+
|
|
130
|
+
A host application's contract package does this for its own base classes."""
|
|
131
|
+
|
|
132
|
+
if not contract or not isinstance(version, int):
|
|
133
|
+
raise ValueError("a contract needs a name and an integer version")
|
|
134
|
+
|
|
135
|
+
def mark(cls):
|
|
136
|
+
contracts = dict(vars(cls).get("__sw_contracts__", {}))
|
|
137
|
+
contracts[contract] = version
|
|
138
|
+
cls.__sw_contracts__ = contracts
|
|
139
|
+
cls.__sw_kinds__ = list(dict.fromkeys([*vars(cls).get("__sw_kinds__", []), *kinds]))
|
|
140
|
+
return cls
|
|
141
|
+
|
|
142
|
+
return mark
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def commands_of(adapter):
|
|
146
|
+
"""Every command on the adapter's class, by wire name. Kinds' base classes add their own."""
|
|
147
|
+
found = {}
|
|
148
|
+
cls = adapter if isinstance(adapter, type) else type(adapter)
|
|
149
|
+
for klass in reversed(cls.__mro__):
|
|
150
|
+
for attr, value in vars(klass).items():
|
|
151
|
+
marker = getattr(value, "__sw_command__", None)
|
|
152
|
+
if marker:
|
|
153
|
+
found[marker["name"]] = attr
|
|
154
|
+
result = {}
|
|
155
|
+
for wire_name, attr in found.items():
|
|
156
|
+
method = getattr(adapter, attr)
|
|
157
|
+
marker = getattr(getattr(cls, attr), "__sw_command__", None) or {}
|
|
158
|
+
result[wire_name] = Command(wire_name, method, marker.get("description"))
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def kinds_of(cls):
|
|
163
|
+
kinds = []
|
|
164
|
+
for klass in cls.__mro__:
|
|
165
|
+
for kind in getattr(klass, "__sw_kinds__", ()) or ():
|
|
166
|
+
if kind not in kinds:
|
|
167
|
+
kinds.append(kind)
|
|
168
|
+
return kinds
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def contracts_of(cls):
|
|
172
|
+
contracts = {}
|
|
173
|
+
for klass in reversed(cls.__mro__):
|
|
174
|
+
contracts.update(getattr(klass, "__sw_contracts__", {}) or {})
|
|
175
|
+
return contracts
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def is_resident(cls):
|
|
179
|
+
"""Resident when it has a start hook: it runs until stopped rather than for one session."""
|
|
180
|
+
return callable(getattr(cls, "start", None))
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HPACK (RFC 7541): enough to talk to the host.
|
|
2
|
+
|
|
3
|
+
The adapter sends one set of request headers, encoded as literals without indexing, which every
|
|
4
|
+
decoder accepts. What the host sends back is decoded in full: static and dynamic tables, size
|
|
5
|
+
updates and Huffman-coded strings, since a server is free to use all of them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._huffman import CODES
|
|
9
|
+
|
|
10
|
+
STATIC_TABLE = (
|
|
11
|
+
(":authority", ""), (":method", "GET"), (":method", "POST"), (":path", "/"),
|
|
12
|
+
(":path", "/index.html"), (":scheme", "http"), (":scheme", "https"), (":status", "200"),
|
|
13
|
+
(":status", "204"), (":status", "206"), (":status", "304"), (":status", "400"),
|
|
14
|
+
(":status", "404"), (":status", "500"), ("accept-charset", ""), ("accept-encoding", "gzip, deflate"),
|
|
15
|
+
("accept-language", ""), ("accept-ranges", ""), ("accept", ""), ("access-control-allow-origin", ""),
|
|
16
|
+
("age", ""), ("allow", ""), ("authorization", ""), ("cache-control", ""),
|
|
17
|
+
("content-disposition", ""), ("content-encoding", ""), ("content-language", ""), ("content-length", ""),
|
|
18
|
+
("content-location", ""), ("content-range", ""), ("content-type", ""), ("cookie", ""),
|
|
19
|
+
("date", ""), ("etag", ""), ("expect", ""), ("expires", ""),
|
|
20
|
+
("from", ""), ("host", ""), ("if-match", ""), ("if-modified-since", ""),
|
|
21
|
+
("if-none-match", ""), ("if-range", ""), ("if-unmodified-since", ""), ("last-modified", ""),
|
|
22
|
+
("link", ""), ("location", ""), ("max-forwards", ""), ("proxy-authenticate", ""),
|
|
23
|
+
("proxy-authorization", ""), ("range", ""), ("referer", ""), ("refresh", ""),
|
|
24
|
+
("retry-after", ""), ("server", ""), ("set-cookie", ""), ("strict-transport-security", ""),
|
|
25
|
+
("transfer-encoding", ""), ("user-agent", ""), ("vary", ""), ("via", ""),
|
|
26
|
+
("www-authenticate", ""),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HpackError(Exception):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _huffman_tree():
|
|
35
|
+
# A binary trie over the codes: each node is [child0, child1, symbol].
|
|
36
|
+
root = [None, None, None]
|
|
37
|
+
for symbol, (code, length) in enumerate(CODES):
|
|
38
|
+
node = root
|
|
39
|
+
for bit in range(length - 1, -1, -1):
|
|
40
|
+
b = (code >> bit) & 1
|
|
41
|
+
if node[b] is None:
|
|
42
|
+
node[b] = [None, None, None]
|
|
43
|
+
node = node[b]
|
|
44
|
+
node[2] = symbol
|
|
45
|
+
return root
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_TREE = _huffman_tree()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def huffman_decode(data):
|
|
52
|
+
out = bytearray()
|
|
53
|
+
node = _TREE
|
|
54
|
+
depth = 0
|
|
55
|
+
for byte in data:
|
|
56
|
+
for bit in range(7, -1, -1):
|
|
57
|
+
node = node[(byte >> bit) & 1]
|
|
58
|
+
depth += 1
|
|
59
|
+
if node is None:
|
|
60
|
+
raise HpackError("invalid Huffman code")
|
|
61
|
+
if node[2] is not None:
|
|
62
|
+
if node[2] == 256:
|
|
63
|
+
raise HpackError("EOS symbol in a Huffman string")
|
|
64
|
+
out.append(node[2])
|
|
65
|
+
node = _TREE
|
|
66
|
+
depth = 0
|
|
67
|
+
# Padding is the most significant bits of EOS (all ones), and shorter than a byte.
|
|
68
|
+
if depth > 7:
|
|
69
|
+
raise HpackError("Huffman padding longer than 7 bits")
|
|
70
|
+
return bytes(out)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _decode_int(data, pos, prefix_bits):
|
|
74
|
+
mask = (1 << prefix_bits) - 1
|
|
75
|
+
value = data[pos] & mask
|
|
76
|
+
pos += 1
|
|
77
|
+
if value < mask:
|
|
78
|
+
return value, pos
|
|
79
|
+
shift = 0
|
|
80
|
+
while True:
|
|
81
|
+
if pos >= len(data):
|
|
82
|
+
raise HpackError("truncated integer")
|
|
83
|
+
byte = data[pos]
|
|
84
|
+
pos += 1
|
|
85
|
+
value += (byte & 0x7F) << shift
|
|
86
|
+
shift += 7
|
|
87
|
+
if not byte & 0x80:
|
|
88
|
+
return value, pos
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _encode_int(value, prefix_bits, first_byte_flags=0):
|
|
92
|
+
mask = (1 << prefix_bits) - 1
|
|
93
|
+
if value < mask:
|
|
94
|
+
return bytes([first_byte_flags | value])
|
|
95
|
+
out = bytearray([first_byte_flags | mask])
|
|
96
|
+
value -= mask
|
|
97
|
+
while value >= 0x80:
|
|
98
|
+
out.append((value & 0x7F) | 0x80)
|
|
99
|
+
value >>= 7
|
|
100
|
+
out.append(value)
|
|
101
|
+
return bytes(out)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _decode_string(data, pos):
|
|
105
|
+
if pos >= len(data):
|
|
106
|
+
raise HpackError("truncated string")
|
|
107
|
+
huffman = data[pos] & 0x80
|
|
108
|
+
length, pos = _decode_int(data, pos, 7)
|
|
109
|
+
raw = data[pos:pos + length]
|
|
110
|
+
if len(raw) != length:
|
|
111
|
+
raise HpackError("truncated string")
|
|
112
|
+
pos += length
|
|
113
|
+
return (huffman_decode(raw) if huffman else bytes(raw)).decode("latin-1"), pos
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _encode_string(text):
|
|
117
|
+
raw = text.encode("latin-1")
|
|
118
|
+
return _encode_int(len(raw), 7) + raw
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def encode(headers):
|
|
122
|
+
"""Headers as literals without indexing: never touch either side's dynamic table."""
|
|
123
|
+
out = bytearray()
|
|
124
|
+
for name, value in headers:
|
|
125
|
+
out += b"\x00" + _encode_string(name) + _encode_string(value)
|
|
126
|
+
return bytes(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class Decoder:
|
|
130
|
+
def __init__(self, max_size=4096):
|
|
131
|
+
self.max_size = max_size
|
|
132
|
+
self.size = 0
|
|
133
|
+
self.dynamic = [] # newest first
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _entry_size(name, value):
|
|
137
|
+
return len(name.encode("latin-1")) + len(value.encode("latin-1")) + 32
|
|
138
|
+
|
|
139
|
+
def _evict(self):
|
|
140
|
+
while self.size > self.max_size and self.dynamic:
|
|
141
|
+
name, value = self.dynamic.pop()
|
|
142
|
+
self.size -= self._entry_size(name, value)
|
|
143
|
+
|
|
144
|
+
def _add(self, name, value):
|
|
145
|
+
self.dynamic.insert(0, (name, value))
|
|
146
|
+
self.size += self._entry_size(name, value)
|
|
147
|
+
self._evict()
|
|
148
|
+
|
|
149
|
+
def _lookup(self, index):
|
|
150
|
+
if index <= 0:
|
|
151
|
+
raise HpackError("index 0")
|
|
152
|
+
if index <= len(STATIC_TABLE):
|
|
153
|
+
return STATIC_TABLE[index - 1]
|
|
154
|
+
index -= len(STATIC_TABLE) + 1
|
|
155
|
+
if index >= len(self.dynamic):
|
|
156
|
+
raise HpackError("index past the dynamic table")
|
|
157
|
+
return self.dynamic[index]
|
|
158
|
+
|
|
159
|
+
def decode(self, data):
|
|
160
|
+
headers = []
|
|
161
|
+
pos = 0
|
|
162
|
+
while pos < len(data):
|
|
163
|
+
byte = data[pos]
|
|
164
|
+
if byte & 0x80: # indexed
|
|
165
|
+
index, pos = _decode_int(data, pos, 7)
|
|
166
|
+
headers.append(self._lookup(index))
|
|
167
|
+
elif byte & 0x40: # literal, incremental indexing
|
|
168
|
+
index, pos = _decode_int(data, pos, 6)
|
|
169
|
+
name = self._lookup(index)[0] if index else None
|
|
170
|
+
if name is None:
|
|
171
|
+
name, pos = _decode_string(data, pos)
|
|
172
|
+
value, pos = _decode_string(data, pos)
|
|
173
|
+
self._add(name, value)
|
|
174
|
+
headers.append((name, value))
|
|
175
|
+
elif byte & 0x20: # dynamic table size update
|
|
176
|
+
size, pos = _decode_int(data, pos, 5)
|
|
177
|
+
self.max_size = size
|
|
178
|
+
self._evict()
|
|
179
|
+
else: # literal without indexing (0000) or never indexed (0001)
|
|
180
|
+
index, pos = _decode_int(data, pos, 4)
|
|
181
|
+
name = self._lookup(index)[0] if index else None
|
|
182
|
+
if name is None:
|
|
183
|
+
name, pos = _decode_string(data, pos)
|
|
184
|
+
value, pos = _decode_string(data, pos)
|
|
185
|
+
headers.append((name, value))
|
|
186
|
+
return headers
|