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