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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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