szpont 0.2.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.
- szpont-0.2.0/PKG-INFO +171 -0
- szpont-0.2.0/README.md +144 -0
- szpont-0.2.0/pyproject.toml +52 -0
- szpont-0.2.0/setup.cfg +4 -0
- szpont-0.2.0/szpont/__init__.py +66 -0
- szpont-0.2.0/szpont/errors.py +42 -0
- szpont-0.2.0/szpont/hosting.py +168 -0
- szpont-0.2.0/szpont/mesh.py +247 -0
- szpont-0.2.0/szpont/models.py +521 -0
- szpont-0.2.0/szpont/py.typed +0 -0
- szpont-0.2.0/szpont.egg-info/PKG-INFO +171 -0
- szpont-0.2.0/szpont.egg-info/SOURCES.txt +19 -0
- szpont-0.2.0/szpont.egg-info/dependency_links.txt +1 -0
- szpont-0.2.0/szpont.egg-info/entry_points.txt +2 -0
- szpont-0.2.0/szpont.egg-info/requires.txt +4 -0
- szpont-0.2.0/szpont.egg-info/top_level.txt +2 -0
- szpont-0.2.0/szpont_launcher.py +359 -0
- szpont-0.2.0/tests/test_hosting.py +199 -0
- szpont-0.2.0/tests/test_launcher.py +396 -0
- szpont-0.2.0/tests/test_mesh.py +324 -0
- szpont-0.2.0/tests/test_models.py +366 -0
szpont-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: szpont
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Install-and-run for Diplomat, and typed Python bindings for SzpontNet, the leaderless LAN peer-to-peer resource-sharing protocol
|
|
5
|
+
Author: Ignacy Łątka
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/latekvo/Diplomat
|
|
8
|
+
Project-URL: Source, https://github.com/latekvo/Diplomat/tree/main/packages/szpont
|
|
9
|
+
Project-URL: Specification, https://github.com/latekvo/Diplomat/tree/main/packages/szpontnet-spec
|
|
10
|
+
Keywords: diplomat,szpontnet,mesh,peer-to-peer,lan,distributed,bindings,launcher
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: System :: Distributed Computing
|
|
20
|
+
Classifier: Topic :: System :: Networking
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: szpontnet<0.6,>=0.5
|
|
25
|
+
Provides-Extra: trust
|
|
26
|
+
Requires-Dist: szpontnet[trust]<0.6,>=0.5; extra == "trust"
|
|
27
|
+
|
|
28
|
+
# szpont
|
|
29
|
+
|
|
30
|
+
Typed Python bindings for **[SzpontNet](../szpontnet-core/README.md)**, the
|
|
31
|
+
leaderless LAN protocol for self-discovery, resource advertisement and work
|
|
32
|
+
hand-off.
|
|
33
|
+
|
|
34
|
+
SzpontNet's reference node is the [`szpontnet`](../szpontnet-core/README.md)
|
|
35
|
+
package, and its public surface is the **wire format**: JSON snapshots,
|
|
36
|
+
string-keyed dictionaries, `lastSeenSecsAgo`, and one exception class for every
|
|
37
|
+
way a control session can fail. That is the right shape for a protocol and an
|
|
38
|
+
awkward one to program against.
|
|
39
|
+
|
|
40
|
+
`szpont` is that surface bound to Python types. It adds nothing to the protocol
|
|
41
|
+
and reimplements none of it - every call here is the corresponding `szpontnet`
|
|
42
|
+
call with its answer parsed and its failure classified.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import szpont
|
|
46
|
+
|
|
47
|
+
mesh = szpont.Mesh()
|
|
48
|
+
|
|
49
|
+
for peer in mesh.status().peers:
|
|
50
|
+
print(peer.name, peer.link, peer.quota.surplus, peer.trust)
|
|
51
|
+
|
|
52
|
+
result = mesh.dispatch("review", prompt, work_key="review:owner/repo#41")
|
|
53
|
+
if result.suppressed:
|
|
54
|
+
print("another machine already has this one")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## What it gives you over the dictionaries
|
|
58
|
+
|
|
59
|
+
**Types.** `Snapshot`, `Node`, `Peer`, `Assignment`, `Dispatch`, `Slot`, `Claim`,
|
|
60
|
+
`Quota`, `Device`, `Tor` - with the questions you actually ask as properties:
|
|
61
|
+
`peer.up`, `peer.personal`, `assignment.satisfied`, `result.ok`, `slot.suppressed`.
|
|
62
|
+
|
|
63
|
+
Parsing never raises. A snapshot can be truncated mid-write, written by a newer
|
|
64
|
+
protocol version, or hand-edited by someone hostile; the library's own contract is
|
|
65
|
+
that such a file degrades to "no node" rather than crashing whoever renders it,
|
|
66
|
+
and these types inherit that. Every object also keeps the dict it was read from as
|
|
67
|
+
`.raw`, because the protocol grows by adding fields - `onion`, `stats` and `sig`
|
|
68
|
+
all arrived that way - and bindings that exposed only what they knew about would
|
|
69
|
+
hide the next one.
|
|
70
|
+
|
|
71
|
+
**Failures you can branch on.** The library raises one `CtlError` for every
|
|
72
|
+
control-session failure and puts the difference in the message. This package
|
|
73
|
+
splits it along the line that decides what a caller should do:
|
|
74
|
+
|
|
75
|
+
| | meaning | what to do |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `NodeUnavailable` | there was no node to talk to, so nothing was attempted | start one, or retry |
|
|
78
|
+
| `CommandRejected` | a node was there and the command did not take effect | retrying sends the same thing |
|
|
79
|
+
|
|
80
|
+
The split is structural, not textual - it turns on whether a live node with a
|
|
81
|
+
usable control port was there, and on whether the library chained a socket error -
|
|
82
|
+
so it does not quietly go wrong the day a message is reworded. Both remain
|
|
83
|
+
`szpontnet.ctl.CtlError`, so code already written against the library keeps working.
|
|
84
|
+
|
|
85
|
+
**Hosting without a subclass.** A node asks its *host* the five things it cannot
|
|
86
|
+
answer alone. The library's way to answer is to subclass `szpontnet.host.Host`;
|
|
87
|
+
most hosts answer one or two of them, and a class for that is ceremony:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
szpont.register_host(
|
|
91
|
+
duties=["render"],
|
|
92
|
+
run_job=lambda prompt, done_path: my_queue.submit(prompt, done_path),
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Anything you leave out keeps the library's default, which is a real answer rather
|
|
97
|
+
than a placeholder: no runner means this machine declines work and the dispatcher
|
|
98
|
+
fails over to the next candidate.
|
|
99
|
+
|
|
100
|
+
This registers **in-process**. A node your application *spawns* is a separate
|
|
101
|
+
process and cannot see it - point that one at a module with `SZPONTNET_HOST`.
|
|
102
|
+
|
|
103
|
+
## Reading is two calls, on purpose
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
mesh.snapshot() # state.json: a file read, no socket, no node needed
|
|
107
|
+
mesh.status() # the node itself: fresher, needs a live node
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
They cost different things. `snapshot()` is what a UI polls every couple of
|
|
111
|
+
seconds; it can lag the node slightly and it survives the node's death, so check
|
|
112
|
+
`mesh.running` before treating what it says as current. `status()` is what you
|
|
113
|
+
call before acting on what you read.
|
|
114
|
+
|
|
115
|
+
## Install
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install szpont # the bindings and the node
|
|
119
|
+
pip install 'szpont[trust]' # ... with Ed25519 device identity
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Without the `trust` extra a node still runs, keyless: it advertises no public key,
|
|
123
|
+
can never be verified, and so is foreign to any peer with a trust allowlist.
|
|
124
|
+
|
|
125
|
+
## The command of the same name
|
|
126
|
+
|
|
127
|
+
The distribution also installs `szpont`, which fetches, builds and starts
|
|
128
|
+
**[Diplomat](../../README.md#install)** - the applet this protocol was written for.
|
|
129
|
+
`szpont --plan` prints what it would do without doing any of it.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
pip install szpont && szpont # the same thing `npx szpont` does
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
It is `szpont_launcher.py`, a top-level module rather than part of this package,
|
|
136
|
+
and it imports nothing outside the standard library: starting an applet has no use
|
|
137
|
+
for the protocol library, and `import szpont` has no use for a launcher. Its twin
|
|
138
|
+
is [`packages/szpont-npm`](../szpont-npm/README.md), which publishes the same
|
|
139
|
+
command to npm; the two are held to the same plan by a parity test.
|
|
140
|
+
|
|
141
|
+
## What it does not do
|
|
142
|
+
|
|
143
|
+
Wrap what already has a good shape. The node itself, its CLI (`python -m
|
|
144
|
+
szpontnet`), the placement function and the protocol codec are all reached
|
|
145
|
+
through `szpontnet` directly and are not duplicated here.
|
|
146
|
+
|
|
147
|
+
## Its own tests
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
pip install -e ./packages/szpontnet-core -e ./packages/szpont pytest
|
|
151
|
+
pytest packages/szpont/tests -q
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The suite refuses to open a socket. That is not tidiness: a test's state directory
|
|
155
|
+
is isolated but the port inside a snapshot is not, and a fixture naming the
|
|
156
|
+
protocol's default 40878 is naming exactly the port a developer's own node is
|
|
157
|
+
listening on. A test that reached the transport would drive that live node - and
|
|
158
|
+
pass while doing it - so the transport is removed and reaching it is the failure.
|
|
159
|
+
|
|
160
|
+
## Note on the name
|
|
161
|
+
|
|
162
|
+
`szpont` names three things, and they share the namespace rather than compete for
|
|
163
|
+
it: this import package (`import szpont`), the launcher installed as the `szpont`
|
|
164
|
+
command, and the [conformance tester](../szpontnet-spec/conformance/README.md), run
|
|
165
|
+
as `python -m szpont` from its own directory.
|
|
166
|
+
|
|
167
|
+
Only the last is precarious. The tester is never installed, and the working
|
|
168
|
+
directory precedes site-packages on `sys.path`, so it wins from where it is run -
|
|
169
|
+
but publishing *it* under this name is what the three could not share. The console
|
|
170
|
+
script cannot collide with either: an entry point is a file in `bin/`, not a name
|
|
171
|
+
on the import path.
|
szpont-0.2.0/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# szpont
|
|
2
|
+
|
|
3
|
+
Typed Python bindings for **[SzpontNet](../szpontnet-core/README.md)**, the
|
|
4
|
+
leaderless LAN protocol for self-discovery, resource advertisement and work
|
|
5
|
+
hand-off.
|
|
6
|
+
|
|
7
|
+
SzpontNet's reference node is the [`szpontnet`](../szpontnet-core/README.md)
|
|
8
|
+
package, and its public surface is the **wire format**: JSON snapshots,
|
|
9
|
+
string-keyed dictionaries, `lastSeenSecsAgo`, and one exception class for every
|
|
10
|
+
way a control session can fail. That is the right shape for a protocol and an
|
|
11
|
+
awkward one to program against.
|
|
12
|
+
|
|
13
|
+
`szpont` is that surface bound to Python types. It adds nothing to the protocol
|
|
14
|
+
and reimplements none of it - every call here is the corresponding `szpontnet`
|
|
15
|
+
call with its answer parsed and its failure classified.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import szpont
|
|
19
|
+
|
|
20
|
+
mesh = szpont.Mesh()
|
|
21
|
+
|
|
22
|
+
for peer in mesh.status().peers:
|
|
23
|
+
print(peer.name, peer.link, peer.quota.surplus, peer.trust)
|
|
24
|
+
|
|
25
|
+
result = mesh.dispatch("review", prompt, work_key="review:owner/repo#41")
|
|
26
|
+
if result.suppressed:
|
|
27
|
+
print("another machine already has this one")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What it gives you over the dictionaries
|
|
31
|
+
|
|
32
|
+
**Types.** `Snapshot`, `Node`, `Peer`, `Assignment`, `Dispatch`, `Slot`, `Claim`,
|
|
33
|
+
`Quota`, `Device`, `Tor` - with the questions you actually ask as properties:
|
|
34
|
+
`peer.up`, `peer.personal`, `assignment.satisfied`, `result.ok`, `slot.suppressed`.
|
|
35
|
+
|
|
36
|
+
Parsing never raises. A snapshot can be truncated mid-write, written by a newer
|
|
37
|
+
protocol version, or hand-edited by someone hostile; the library's own contract is
|
|
38
|
+
that such a file degrades to "no node" rather than crashing whoever renders it,
|
|
39
|
+
and these types inherit that. Every object also keeps the dict it was read from as
|
|
40
|
+
`.raw`, because the protocol grows by adding fields - `onion`, `stats` and `sig`
|
|
41
|
+
all arrived that way - and bindings that exposed only what they knew about would
|
|
42
|
+
hide the next one.
|
|
43
|
+
|
|
44
|
+
**Failures you can branch on.** The library raises one `CtlError` for every
|
|
45
|
+
control-session failure and puts the difference in the message. This package
|
|
46
|
+
splits it along the line that decides what a caller should do:
|
|
47
|
+
|
|
48
|
+
| | meaning | what to do |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `NodeUnavailable` | there was no node to talk to, so nothing was attempted | start one, or retry |
|
|
51
|
+
| `CommandRejected` | a node was there and the command did not take effect | retrying sends the same thing |
|
|
52
|
+
|
|
53
|
+
The split is structural, not textual - it turns on whether a live node with a
|
|
54
|
+
usable control port was there, and on whether the library chained a socket error -
|
|
55
|
+
so it does not quietly go wrong the day a message is reworded. Both remain
|
|
56
|
+
`szpontnet.ctl.CtlError`, so code already written against the library keeps working.
|
|
57
|
+
|
|
58
|
+
**Hosting without a subclass.** A node asks its *host* the five things it cannot
|
|
59
|
+
answer alone. The library's way to answer is to subclass `szpontnet.host.Host`;
|
|
60
|
+
most hosts answer one or two of them, and a class for that is ceremony:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
szpont.register_host(
|
|
64
|
+
duties=["render"],
|
|
65
|
+
run_job=lambda prompt, done_path: my_queue.submit(prompt, done_path),
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Anything you leave out keeps the library's default, which is a real answer rather
|
|
70
|
+
than a placeholder: no runner means this machine declines work and the dispatcher
|
|
71
|
+
fails over to the next candidate.
|
|
72
|
+
|
|
73
|
+
This registers **in-process**. A node your application *spawns* is a separate
|
|
74
|
+
process and cannot see it - point that one at a module with `SZPONTNET_HOST`.
|
|
75
|
+
|
|
76
|
+
## Reading is two calls, on purpose
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
mesh.snapshot() # state.json: a file read, no socket, no node needed
|
|
80
|
+
mesh.status() # the node itself: fresher, needs a live node
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
They cost different things. `snapshot()` is what a UI polls every couple of
|
|
84
|
+
seconds; it can lag the node slightly and it survives the node's death, so check
|
|
85
|
+
`mesh.running` before treating what it says as current. `status()` is what you
|
|
86
|
+
call before acting on what you read.
|
|
87
|
+
|
|
88
|
+
## Install
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
pip install szpont # the bindings and the node
|
|
92
|
+
pip install 'szpont[trust]' # ... with Ed25519 device identity
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Without the `trust` extra a node still runs, keyless: it advertises no public key,
|
|
96
|
+
can never be verified, and so is foreign to any peer with a trust allowlist.
|
|
97
|
+
|
|
98
|
+
## The command of the same name
|
|
99
|
+
|
|
100
|
+
The distribution also installs `szpont`, which fetches, builds and starts
|
|
101
|
+
**[Diplomat](../../README.md#install)** - the applet this protocol was written for.
|
|
102
|
+
`szpont --plan` prints what it would do without doing any of it.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install szpont && szpont # the same thing `npx szpont` does
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
It is `szpont_launcher.py`, a top-level module rather than part of this package,
|
|
109
|
+
and it imports nothing outside the standard library: starting an applet has no use
|
|
110
|
+
for the protocol library, and `import szpont` has no use for a launcher. Its twin
|
|
111
|
+
is [`packages/szpont-npm`](../szpont-npm/README.md), which publishes the same
|
|
112
|
+
command to npm; the two are held to the same plan by a parity test.
|
|
113
|
+
|
|
114
|
+
## What it does not do
|
|
115
|
+
|
|
116
|
+
Wrap what already has a good shape. The node itself, its CLI (`python -m
|
|
117
|
+
szpontnet`), the placement function and the protocol codec are all reached
|
|
118
|
+
through `szpontnet` directly and are not duplicated here.
|
|
119
|
+
|
|
120
|
+
## Its own tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e ./packages/szpontnet-core -e ./packages/szpont pytest
|
|
124
|
+
pytest packages/szpont/tests -q
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The suite refuses to open a socket. That is not tidiness: a test's state directory
|
|
128
|
+
is isolated but the port inside a snapshot is not, and a fixture naming the
|
|
129
|
+
protocol's default 40878 is naming exactly the port a developer's own node is
|
|
130
|
+
listening on. A test that reached the transport would drive that live node - and
|
|
131
|
+
pass while doing it - so the transport is removed and reaching it is the failure.
|
|
132
|
+
|
|
133
|
+
## Note on the name
|
|
134
|
+
|
|
135
|
+
`szpont` names three things, and they share the namespace rather than compete for
|
|
136
|
+
it: this import package (`import szpont`), the launcher installed as the `szpont`
|
|
137
|
+
command, and the [conformance tester](../szpontnet-spec/conformance/README.md), run
|
|
138
|
+
as `python -m szpont` from its own directory.
|
|
139
|
+
|
|
140
|
+
Only the last is precarious. The tester is never installed, and the working
|
|
141
|
+
directory precedes site-packages on `sys.path`, so it wins from where it is run -
|
|
142
|
+
but publishing *it* under this name is what the three could not share. The console
|
|
143
|
+
script cannot collide with either: an entry point is a file in `bin/`, not a name
|
|
144
|
+
on the import path.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "szpont"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Install-and-run for Diplomat, and typed Python bindings for SzpontNet, the leaderless LAN peer-to-peer resource-sharing protocol"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Ignacy Łątka" }]
|
|
13
|
+
keywords = ["diplomat", "szpontnet", "mesh", "peer-to-peer", "lan", "distributed", "bindings", "launcher"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: System :: Distributed Computing",
|
|
24
|
+
"Topic :: System :: Networking",
|
|
25
|
+
"Typing :: Typed",
|
|
26
|
+
]
|
|
27
|
+
# The node itself. This package is bindings for it and has no independent
|
|
28
|
+
# behaviour, so the dependency is required rather than an extra.
|
|
29
|
+
dependencies = ["szpontnet>=0.5,<0.6"]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
# Passed through to the library: Ed25519 device identity. Without it a node runs
|
|
33
|
+
# keyless, advertises no public key, and is foreign to any peer with an allowlist.
|
|
34
|
+
trust = ["szpontnet[trust]>=0.5,<0.6"]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
# `pip install szpont` then `szpont`. The launcher is a top-level module rather
|
|
38
|
+
# than part of the package on purpose - see its docstring; nothing about starting
|
|
39
|
+
# an applet needs the protocol library that importing `szpont` would pull in.
|
|
40
|
+
szpont = "szpont_launcher:main"
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/latekvo/Diplomat"
|
|
44
|
+
Source = "https://github.com/latekvo/Diplomat/tree/main/packages/szpont"
|
|
45
|
+
Specification = "https://github.com/latekvo/Diplomat/tree/main/packages/szpontnet-spec"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools]
|
|
48
|
+
packages = ["szpont"]
|
|
49
|
+
py-modules = ["szpont_launcher"]
|
|
50
|
+
|
|
51
|
+
[tool.setuptools.package-data]
|
|
52
|
+
szpont = ["py.typed"]
|
szpont-0.2.0/setup.cfg
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""szpont - a typed Python API for SzpontNet.
|
|
2
|
+
|
|
3
|
+
SzpontNet is a leaderless LAN protocol: machines find each other over UDP, gossip
|
|
4
|
+
what they can do, and agree with no coordinator on which machine owns each class
|
|
5
|
+
of work. Its reference node is the ``szpontnet`` package, and that package's
|
|
6
|
+
public surface is the wire format - JSON snapshots, string-keyed dictionaries, and
|
|
7
|
+
one exception class for every way a control session can fail.
|
|
8
|
+
|
|
9
|
+
This package is that surface bound to Python types. It adds nothing to the
|
|
10
|
+
protocol and re-implements none of it; every call here is the corresponding
|
|
11
|
+
``szpontnet`` call with its answer parsed and its failure classified.
|
|
12
|
+
|
|
13
|
+
import szpont
|
|
14
|
+
|
|
15
|
+
mesh = szpont.Mesh()
|
|
16
|
+
if mesh.running:
|
|
17
|
+
for peer in mesh.status().peers:
|
|
18
|
+
print(peer.name, peer.link, peer.quota.surplus)
|
|
19
|
+
|
|
20
|
+
result = mesh.dispatch("review", prompt, work_key="review:owner/repo#41")
|
|
21
|
+
if result.suppressed:
|
|
22
|
+
... # another machine already has this one
|
|
23
|
+
|
|
24
|
+
Three things it gives you over the dictionaries:
|
|
25
|
+
|
|
26
|
+
* **Types.** :class:`~szpont.models.Snapshot`, :class:`~szpont.models.Peer`,
|
|
27
|
+
:class:`~szpont.models.Dispatch` and friends, with the questions you actually
|
|
28
|
+
ask as properties - ``peer.up``, ``assignment.satisfied``, ``result.ok``.
|
|
29
|
+
Parsing never raises, and every object keeps the dict it came from as ``raw``,
|
|
30
|
+
so a field this package has not heard of is still reachable.
|
|
31
|
+
* **Failures you can branch on.** :class:`~szpont.errors.NodeUnavailable` (there
|
|
32
|
+
was no node, so nothing happened) against
|
|
33
|
+
:class:`~szpont.errors.CommandRejected` (there was, and it did not do it).
|
|
34
|
+
Both remain :class:`szpontnet.ctl.CtlError`.
|
|
35
|
+
* **Hosting without a subclass.** :func:`register_host` takes the answers a node
|
|
36
|
+
needs from its host as plain functions.
|
|
37
|
+
|
|
38
|
+
The library itself stays right there: ``import szpontnet`` for the node, its CLI
|
|
39
|
+
(``python -m szpontnet``) and everything this package deliberately does not
|
|
40
|
+
duplicate.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from .errors import CommandRejected, NodeUnavailable, SzpontError
|
|
46
|
+
from .hosting import (Host, NoRunner, build_host, duty_model, register_host,
|
|
47
|
+
unregister_host)
|
|
48
|
+
from .mesh import DEFAULT_DISPATCH_TIMEOUT, DEFAULT_TIMEOUT, Mesh
|
|
49
|
+
from .models import (NEUTRAL_SURPLUS, Assignment, Claim, Device, Dispatch, Node,
|
|
50
|
+
Peer, Quota, Shortfall, Slot, Snapshot, Tor)
|
|
51
|
+
|
|
52
|
+
__version__ = "0.2.0"
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"__version__",
|
|
56
|
+
# client
|
|
57
|
+
"Mesh", "DEFAULT_TIMEOUT", "DEFAULT_DISPATCH_TIMEOUT",
|
|
58
|
+
# errors
|
|
59
|
+
"SzpontError", "NodeUnavailable", "CommandRejected",
|
|
60
|
+
# models
|
|
61
|
+
"Snapshot", "Node", "Peer", "Quota", "Assignment", "Shortfall", "Dispatch",
|
|
62
|
+
"Slot", "Claim", "Device", "Tor", "NEUTRAL_SURPLUS",
|
|
63
|
+
# hosting
|
|
64
|
+
"Host", "NoRunner", "build_host", "register_host", "unregister_host",
|
|
65
|
+
"duty_model",
|
|
66
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""What went wrong, told apart by whether retrying could ever help.
|
|
2
|
+
|
|
3
|
+
The library raises one :class:`szpontnet.ctl.CtlError` for every control-session
|
|
4
|
+
failure, so a caller that wants to react has to read the message to learn which
|
|
5
|
+
kind it got. This package splits that class along the only line that changes what
|
|
6
|
+
a caller should do:
|
|
7
|
+
|
|
8
|
+
* :class:`NodeUnavailable` - there was no node to talk to. Nothing was attempted,
|
|
9
|
+
so nothing happened; start a node, or wait and retry.
|
|
10
|
+
* :class:`CommandRejected` - a node was there and the command did not take
|
|
11
|
+
effect. Retrying the same command gets the same answer.
|
|
12
|
+
|
|
13
|
+
Both are also :class:`szpontnet.ctl.CtlError`, so code that already catches the
|
|
14
|
+
library's own exception keeps catching these unchanged - the split is additive.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from szpontnet.ctl import CtlError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SzpontError(Exception):
|
|
23
|
+
"""Base class for everything this package raises."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NodeUnavailable(SzpontError, CtlError):
|
|
27
|
+
"""No local node was reachable, so the command was never put to one.
|
|
28
|
+
|
|
29
|
+
Raised when no node has ever run against this state directory, when the one
|
|
30
|
+
that did is dead, when its snapshot names no usable control port, or when the
|
|
31
|
+
socket to it fails. In every case the mesh saw nothing.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CommandRejected(SzpontError, CtlError):
|
|
36
|
+
"""A node was reached and the command did not take effect.
|
|
37
|
+
|
|
38
|
+
Either it answered with an error - an unknown duty, a bad attribute, a
|
|
39
|
+
missing API key - or it ended the control session without answering at all.
|
|
40
|
+
The two are one class because they leave the caller in the same position: the
|
|
41
|
+
node is up, and this command is not going to work as sent.
|
|
42
|
+
"""
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Putting your application behind a node, without writing a class.
|
|
2
|
+
|
|
3
|
+
A node asks its **host** the five things it cannot answer alone: which duties this
|
|
4
|
+
deployment routes, where its state lives, where its events go, what running a job
|
|
5
|
+
means here, and whether that work is already under way on this machine
|
|
6
|
+
(:mod:`szpontnet.host`). The library's own way to answer is to subclass
|
|
7
|
+
:class:`~szpontnet.host.Host` and override what you answer differently.
|
|
8
|
+
|
|
9
|
+
Most hosts answer one or two of those - usually just :func:`run_job` - and a class
|
|
10
|
+
for that is ceremony. :func:`register` takes the answers as functions:
|
|
11
|
+
|
|
12
|
+
import szpont
|
|
13
|
+
|
|
14
|
+
szpont.register_host(
|
|
15
|
+
duties=["render"],
|
|
16
|
+
run_job=lambda prompt, done_path: my_queue.submit(prompt, done_path),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
Whatever you leave out keeps the library's default, which is a real answer and not
|
|
20
|
+
a placeholder: no runner means this machine declines work and the dispatcher fails
|
|
21
|
+
over to the next candidate, which is exactly what a machine with nothing to run it
|
|
22
|
+
should say.
|
|
23
|
+
|
|
24
|
+
This registers **in-process**. A node your application *spawns* is a separate
|
|
25
|
+
process and cannot see it - point that one at a module with ``SZPONTNET_HOST``,
|
|
26
|
+
as :mod:`szpontnet.host` describes.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from collections.abc import Callable, Iterable
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from szpontnet import host as _host
|
|
35
|
+
from szpontnet.host import Host, NoRunner
|
|
36
|
+
|
|
37
|
+
__all__ = ["Host", "NoRunner", "build_host", "register_host", "unregister_host",
|
|
38
|
+
"duty_model"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def duty_model(duties: Iterable[str], *, token_aware: bool = True,
|
|
42
|
+
spread: Iterable[tuple[str, int]] = ()) -> dict:
|
|
43
|
+
"""A network model that routes exactly ``duties``, for the common case.
|
|
44
|
+
|
|
45
|
+
The duty catalog is replaced wholesale rather than merged, so a deployment
|
|
46
|
+
that names its duties gets *those* duties and not those plus the canonical
|
|
47
|
+
``review``/``conflicts``/``audit``. Every duty here shares one placement;
|
|
48
|
+
a deployment that needs them to differ writes the model out by hand and
|
|
49
|
+
passes it as ``model`` instead.
|
|
50
|
+
|
|
51
|
+
``token_aware`` excludes machines that are out of tokens, and ``spread`` is
|
|
52
|
+
the ``(platform, count)`` staffing a duty requires - an empty spread means one
|
|
53
|
+
slot on whichever machine ranks best.
|
|
54
|
+
|
|
55
|
+
Each spread pair is written out as the ``{"platform", "count"}`` object the
|
|
56
|
+
schema defines. That shape is load-bearing rather than cosmetic: placements
|
|
57
|
+
arrive over gossip too, so the library skips any spread entry that is not an
|
|
58
|
+
object - a pair emitted as a two-element list would not be rejected, it would
|
|
59
|
+
silently resolve to no spread at all, and the duty would staff one machine
|
|
60
|
+
instead of the platforms asked for.
|
|
61
|
+
"""
|
|
62
|
+
slots = [{"platform": platform, "count": int(count)} for platform, count in spread]
|
|
63
|
+
# A fresh placement per duty, so a caller that edits one duty's spread in the
|
|
64
|
+
# returned model does not silently edit every other duty's too.
|
|
65
|
+
return {"duties": [{"id": duty,
|
|
66
|
+
"placement": {"tokenAware": bool(token_aware),
|
|
67
|
+
"spread": [dict(s) for s in slots]}}
|
|
68
|
+
for duty in duties]}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def build_host(
|
|
72
|
+
*,
|
|
73
|
+
model: Callable[[], dict] | dict | None = None,
|
|
74
|
+
duties: Iterable[str] | None = None,
|
|
75
|
+
state_dir: Callable[[], Path | str] | Path | str | None = None,
|
|
76
|
+
log: Callable[[str, str], None] | None = None,
|
|
77
|
+
run_job: Callable[[str, str | None], str] | None = None,
|
|
78
|
+
work_already_running: Callable[[str], bool] | None = None,
|
|
79
|
+
) -> Host:
|
|
80
|
+
"""A :class:`~szpontnet.host.Host` answering only what you gave it.
|
|
81
|
+
|
|
82
|
+
Each argument takes either a callable, which the node calls when it needs the
|
|
83
|
+
answer, or a plain value, which is treated as a callable returning it. Anything
|
|
84
|
+
omitted falls through to the library's default.
|
|
85
|
+
|
|
86
|
+
``duties`` is shorthand for ``model=duty_model(duties)`` and cannot be combined
|
|
87
|
+
with an explicit ``model``.
|
|
88
|
+
"""
|
|
89
|
+
if duties is not None:
|
|
90
|
+
if model is not None:
|
|
91
|
+
raise ValueError("pass either `duties` or `model`, not both - "
|
|
92
|
+
"`duties` is shorthand for a model that routes them")
|
|
93
|
+
model = duty_model(duties)
|
|
94
|
+
|
|
95
|
+
return _CallableHost(
|
|
96
|
+
model=_as_callable(model),
|
|
97
|
+
state_dir=_as_path_callable(state_dir),
|
|
98
|
+
log=log,
|
|
99
|
+
run_job=run_job,
|
|
100
|
+
work_already_running=work_already_running,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def register_host(**answers) -> Host:
|
|
105
|
+
"""Build a host from :func:`build_host`'s arguments and put it behind the
|
|
106
|
+
node in this process. Returns it, so a caller can hold on to it.
|
|
107
|
+
|
|
108
|
+
The host is process-global and there is one: registering replaces whoever was
|
|
109
|
+
there. It also overrides ``SZPONTNET_HOST``, since an application driving the
|
|
110
|
+
node's modules directly is more specific than the environment it inherited.
|
|
111
|
+
"""
|
|
112
|
+
host = build_host(**answers)
|
|
113
|
+
_host.set_host(host)
|
|
114
|
+
return host
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def unregister_host() -> None:
|
|
118
|
+
"""Take the registered host away - back to the library's own defaults."""
|
|
119
|
+
_host.reset_host()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _as_callable(value):
|
|
123
|
+
if value is None or callable(value):
|
|
124
|
+
return value
|
|
125
|
+
return lambda: value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _as_path_callable(value):
|
|
129
|
+
if value is None:
|
|
130
|
+
return None
|
|
131
|
+
if callable(value):
|
|
132
|
+
return lambda: Path(value())
|
|
133
|
+
resolved = Path(value)
|
|
134
|
+
return lambda: resolved
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _CallableHost(Host):
|
|
138
|
+
"""A host whose answers are the functions it was given.
|
|
139
|
+
|
|
140
|
+
Each method delegates when it has a function for that question and falls
|
|
141
|
+
through to :class:`~szpontnet.host.Host`'s own answer when it does not - so
|
|
142
|
+
an omitted ``run_job`` still raises :class:`~szpontnet.host.NoRunner`, which
|
|
143
|
+
the dispatcher handles as the ordinary decline it is.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(self, **answers) -> None:
|
|
147
|
+
self._answers = {name: fn for name, fn in answers.items() if fn is not None}
|
|
148
|
+
|
|
149
|
+
def model(self) -> dict:
|
|
150
|
+
fn = self._answers.get("model")
|
|
151
|
+
return fn() if fn else super().model()
|
|
152
|
+
|
|
153
|
+
def state_dir(self) -> Path:
|
|
154
|
+
fn = self._answers.get("state_dir")
|
|
155
|
+
return fn() if fn else super().state_dir()
|
|
156
|
+
|
|
157
|
+
def log(self, action: str, detail: str) -> None:
|
|
158
|
+
fn = self._answers.get("log")
|
|
159
|
+
if fn:
|
|
160
|
+
fn(action, detail)
|
|
161
|
+
|
|
162
|
+
def run_job(self, prompt: str, done_path: str | None) -> str:
|
|
163
|
+
fn = self._answers.get("run_job")
|
|
164
|
+
return fn(prompt, done_path) if fn else super().run_job(prompt, done_path)
|
|
165
|
+
|
|
166
|
+
def work_already_running(self, work_key: str) -> bool:
|
|
167
|
+
fn = self._answers.get("work_already_running")
|
|
168
|
+
return bool(fn(work_key)) if fn else super().work_already_running(work_key)
|