pyshal 0.1.0__py3-none-any.whl

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,385 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyshal
3
+ Version: 0.1.0
4
+ Summary: System/Software Hardware Abstraction Layer — device-tree-inspired, dynamic, user-space, network-capable
5
+ Author: SHAL contributors
6
+ License: MIT
7
+ Project-URL: Documentation, https://github.com/determlab/shal#readme
8
+ Project-URL: Changelog, https://github.com/determlab/shal/blob/main/CHANGELOG.md
9
+ Project-URL: Source, https://github.com/determlab/shal
10
+ Keywords: hardware,hal,device-tree,i2c,spi,ssh,embedded,lab-automation,robotics,instrumentation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: System :: Hardware
22
+ Classifier: Topic :: Software Development :: Embedded Systems
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: jsonschema>=4.18
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7; extra == "dev"
31
+ Requires-Dist: ruff>=0.4; extra == "dev"
32
+ Dynamic: license-file
33
+
34
+ <div align="center">
35
+
36
+ # SHAL
37
+
38
+ ### Give an AI agent real hardware — gated, so it asks before it moves.
39
+
40
+ Describe your whole lab — sensors, instruments, robots, services — in **one YAML
41
+ file**, and hand it to an LLM as **typed, permission-gated tools**: writes stop
42
+ for approval before they reach the device; reads don't. No transport code, no glue.
43
+
44
+ > In a blind test, an agent wrote working, safety-checked drivers for **4 of 4**
45
+ > devices from documentation alone — then drove a **real robot, gated**.
46
+
47
+ <!-- BADGES -->
48
+ [![GitHub stars](https://img.shields.io/github/stars/determlab/shal?style=social)](https://github.com/determlab/shal)
49
+ [![PyPI](https://img.shields.io/badge/PyPI-coming_soon-blue)](#install)
50
+ [![Python](https://img.shields.io/badge/python-3.10+-blue)](#install)
51
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
52
+ [![Status](https://img.shields.io/badge/status-alpha-orange)](#roadmap)
53
+
54
+ <picture>
55
+ <source srcset="docs/assets/SHAL_banner.webp" type="image/webp">
56
+ <img alt="SHAL turns your lab and services into one YAML topology — controlled from Python or exposed to an AI agent as typed, gated tools" src="docs/assets/SHAL_banner.png" width="100%">
57
+ </picture>
58
+
59
+
60
+ ### [→ Try it in 60 seconds — no hardware required](#quick-start)
61
+
62
+ </div>
63
+
64
+ **Built for:**
65
+
66
+ ✓ AI agent builders &nbsp;·&nbsp; ✓ Validation & test engineers &nbsp;·&nbsp;
67
+ ✓ Hardware-in-the-loop automation &nbsp;·&nbsp; ✓ Labs with mixed hardware + software
68
+
69
+ ---
70
+
71
+ ## From glue scripts to agent tools — in three steps
72
+
73
+ **Step 1 · Today, without SHAL** — a separate library, address, and retry per device:
74
+
75
+ ```python
76
+ sensor = TMP102(i2c_bus, 0x48)
77
+ supply = SCPIPowerSupply("10.0.0.50:5025")
78
+ results = RESTClient("https://mes.lab.internal")
79
+ # ...and you wire each one's retries, logging, and tool-wrapper by hand
80
+ ```
81
+
82
+ **Step 2 · With SHAL** — describe the rack once, then call devices by name:
83
+
84
+ ```python
85
+ hal = shal.load("lab.yaml")
86
+
87
+ hal.get_device("ambient_temp").read_celsius() # I²C sensor
88
+ hal.get_device("dut_power").set_voltage(3.3) # SCPI supply
89
+ hal.get_device("results_db").record(status="pass") # HTTP service
90
+ ```
91
+
92
+ > **Validation & test engineers can stop here.** One model for the whole rack —
93
+ > no agent needed, no transport code, no glue.
94
+
95
+ **Step 3 · Hand the same rack to an agent** — the tool catalog is generated for you:
96
+
97
+ ```python
98
+ tools = hal.tool_schemas() # one typed tool per device op
99
+ hal.call_tool("dut_power__set_voltage", {"volts": 3.3})
100
+ ```
101
+
102
+ > Writes are gated, reads aren't. The agent never sees SCPI, I²C, or an address.
103
+
104
+ ---
105
+
106
+ ## Why existing agent frameworks fall short
107
+
108
+ Most agent tooling assumes **software-only** tools: APIs, databases, functions.
109
+ The moment a tool is a *physical* device — a sensor on I²C, an instrument over a
110
+ raw socket, a robot behind a network hop — you're on your own.
111
+
112
+ SHAL exposes physical devices, remote labs, instruments, **and** software
113
+ services as the *same* kind of tool — with the safety rails physical actions
114
+ need: gated writes, honest failure, a full audit trail.
115
+
116
+ ---
117
+
118
+ ## One model for hardware *and* software
119
+
120
+ The core idea is small:
121
+
122
+ > **A bus is just a node that provides a transport to its children.**
123
+
124
+ A sensor on I²C and an HTTP service are the same kind of node. Your code — and
125
+ your agent — calls **capabilities** (`read_celsius()`, `set_voltage()`), never
126
+ transports. `[core]` ships with SHAL; `[pkg]` is a driver you install or write.
127
+
128
+ ```yaml
129
+ # lab.yaml — hardware and software in ONE graph
130
+ shal_version: 1
131
+ root:
132
+ bench: # one SSH hop to the bench controller [core]
133
+ driver: shal,ssh-host
134
+ address: ${BENCH_SSH} # secrets resolve from the environment, never logged
135
+ children:
136
+ i2c0: # I²C rendered as argv over the SSH hop [core]
137
+ driver: shal,i2c-cli
138
+ address: /dev/i2c-1
139
+ children:
140
+ ambient: { id: ambient_temp, driver: ti,tmp102, address: 0x48 } # [core]
141
+
142
+ instruments: # raw-socket SCPI bus [pkg]
143
+ driver: acme,scpi
144
+ address: 10.0.0.50:5025
145
+ children:
146
+ supply: { id: dut_power, driver: keysight,e36312, address: ch1 } # [pkg]
147
+
148
+ services: # HTTPS to internal services [core]
149
+ driver: shal,http
150
+ address: https://mes.lab.internal
151
+ children:
152
+ results: { id: results_db, driver: acme,mes-results, address: api/v2 } # [pkg]
153
+ ```
154
+
155
+ Every node is reached the same way — `hal.get_device("dut_power").set_voltage(3.3)`
156
+ — sensor or database, local or across the network. Same retries, same logs. Swap
157
+ any node for its sim and **nothing in your code changes**.
158
+
159
+ ---
160
+
161
+ ## Features
162
+
163
+ - **Agent-native** — every device op becomes a gated LLM tool.
164
+ - **Asks before it moves** — actuator & destructive/config ops stop for a
165
+ host-supplied approver (CLI prompt, an agent, or auto in sim/CI); the gate is
166
+ pre-I/O and unbypassable.
167
+ - **Hardware + software, one graph** — a sensor and an HTTP service are the same node.
168
+ - **Capabilities, not wires** — call `read_celsius()`, never I²C.
169
+ - **Retry you can trust** — reads auto-retry; risky writes never silently repeat.
170
+ - **Sim-first** — test the whole rack with zero hardware.
171
+ - **Recursive** — muxes, jumpboxes, nested buses: one primitive, no special cases.
172
+ - **Drivers as plugins** — add a device in one small class.
173
+ - **Secure by default** — no shell strings, TLS on, secrets via `${ENV}`.
174
+ - **Observable** — structured logs, one `txn` id per call.
175
+
176
+ ---
177
+
178
+ ## Install
179
+
180
+ > SHAL is in **alpha** (Phase 1). A PyPI release is coming; for now install from
181
+ > source:
182
+
183
+ ```bash
184
+ pip install git+https://github.com/determlab/shal
185
+
186
+ # or, for development
187
+ git clone https://github.com/determlab/shal && cd shal
188
+ pip install -e ".[dev]" # pytest, ruff
189
+ ```
190
+
191
+ Requires **Python ≥ 3.10**. Dependencies: `pyyaml`, `jsonschema`.
192
+
193
+ ---
194
+
195
+ ## Quick Start
196
+
197
+ Runs with **zero hardware** — the simulated bus ships with SHAL. New to hardware?
198
+ This is the whole setup, and it's just Python and a YAML file.
199
+
200
+ ```yaml
201
+ # sim.yaml
202
+ shal_version: 1
203
+ root:
204
+ bus:
205
+ driver: shal,sim-i2c
206
+ address: sim0
207
+ children:
208
+ temp0:
209
+ id: ambient_temp
210
+ driver: ti,tmp102
211
+ address: 0x48
212
+ ```
213
+
214
+ ```python
215
+ import shal
216
+
217
+ with shal.load("sim.yaml") as hal:
218
+ print(hal.get_device("ambient_temp").read_celsius()) # 25.0
219
+ ```
220
+
221
+ ```bash
222
+ $ python quickstart.py
223
+ 25.0
224
+ ```
225
+
226
+ When the real board arrives, change `shal,sim-i2c` → `shal,i2c-cli`. **Your
227
+ Python doesn't change.**
228
+
229
+ ---
230
+
231
+ ## Write a driver in 30 seconds
232
+
233
+ Need a device SHAL doesn't have yet? A driver is one small class. This is the
234
+ *entire* bundled temperature-sensor driver:
235
+
236
+ ```python
237
+ from shal import Driver, TemperatureSensor, registry, idempotent, op, ByteTransport, Read, Write
238
+
239
+ @registry.register
240
+ class Tmp102(Driver, TemperatureSensor):
241
+ compatible = "ti,tmp102" # matched against the YAML `driver:` field
242
+ kind = ByteTransport
243
+ llm_ready = True
244
+
245
+ @idempotent # a read: safe to auto-retry across drops
246
+ @op("Read the ambient temperature now.", unit="celsius", side_effect="none")
247
+ def read_celsius(self) -> float:
248
+ raw = self.bus.txn(self.addr, [Write(b"\x00"), Read(2)])
249
+ return ((raw[0] << 4) | (raw[1] >> 4)) * 0.0625
250
+ ```
251
+
252
+ That's it — register the `compatible`, implement the capability. The `@op`
253
+ metadata is what makes it show up as a gated agent tool. SHAL discovers your
254
+ driver via the `shal.drivers` entry point.
255
+
256
+ ---
257
+
258
+ ## How It Works
259
+
260
+ A topology is a tree, and **every edge is a bus** — itself a node that carries
261
+ traffic to its children. You call a capability; SHAL translates it down the stack
262
+ to the wire and hands the result back up. No layer leaks into the one above.
263
+
264
+ ```mermaid
265
+ sequenceDiagram
266
+ participant U as Your code
267
+ participant T as tmp102 driver
268
+ participant I as i2c-cli bus
269
+ participant S as ssh-host bus
270
+ U->>T: read_celsius()
271
+ T->>I: read register 0x00
272
+ I->>S: i2ctransfer 0x48 … (argv)
273
+ Note over S: runs on lab_server
274
+ S-->>I: raw bytes
275
+ I-->>T: raw bytes
276
+ T-->>U: 22.5 °C
277
+ ```
278
+
279
+ Because every hop is the same primitive, an SSH jumpbox, an I²C mux, and an
280
+ in-process sim all compose — no special cases.
281
+
282
+ ---
283
+
284
+ ## Core Concepts
285
+
286
+ | Concept | What it means |
287
+ |---|---|
288
+ | **Node** | Anything in the tree: a device, a bus, a board. |
289
+ | **Bus** | A node that provides a transport to its children (I²C, SSH, HTTP…). |
290
+ | **Driver** | Bound to a node by its `compatible` string. Implements a capability. |
291
+ | **Capability** | The typed API your code calls (`read_celsius()`), independent of transport. |
292
+ | **id vs path** | `id` is a stable name for lookup; `path` is where it sits. Move a device, keep its `id`. |
293
+
294
+ ```python
295
+ hal.get_device("ambient_temp").read_celsius() # by semantic id — no wires leak in
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Real-World Use Cases
301
+
302
+ - **AI agents with real-world access** — expose a lab or robot to an LLM as
303
+ gated tools; every actuator call stops for a human (or policy) to approve
304
+ before it fires, and a delivery-unknown write is never silently retried.
305
+ - **Validation & test racks** — one model for eval boards, instruments, and the
306
+ results database; test against sims in CI before hardware.
307
+ - **Manufacturing lines** — same capability calls across stations; one audit
308
+ trail (`shal.audit`) for every actuator command.
309
+ - **Remote & distributed setups** — drive hardware behind an SSH jumpbox with
310
+ nothing on the far side but standard CLI tools.
311
+ - **Robotics bringup** — start against a sim, swap in transports as boards land,
312
+ without rewriting control code.
313
+
314
+ ---
315
+
316
+ ## FAQ
317
+
318
+ **Why not just wrap Python libraries as agent tools myself?**
319
+ You can — until there are ten devices on four transports, some behind an SSH hop,
320
+ some not. Then you're hand-maintaining a tool wrapper, address, retry policy, and
321
+ audit log *per device*. SHAL generates all of it from one topology.
322
+
323
+ **Is it production-ready?**
324
+ It's **alpha** (Phase 1). The synchronous core — topology, drivers, buses, retry
325
+ policy, and the agent tool surface — is real and tested. Async/streaming, the
326
+ actuator watchdog, and route failover are Phase 2 ([roadmap](#roadmap)).
327
+
328
+ **Do I need real hardware to try it?**
329
+ No. The bundled simulated bus runs the [Quick Start](#quick-start) with zero
330
+ hardware — swap in a real transport later, and your code doesn't change.
331
+
332
+ ---
333
+
334
+ ## Roadmap
335
+
336
+ **Shipped — Phase 1 (synchronous core, v0.1.0):**
337
+
338
+ - ✅ Declarative YAML topology: JSON-Schema validation, `id`/`path`/`$ref`,
339
+ `${ENV}` secrets, reusable `template:` includes
340
+ - ✅ Bundled buses: `sim-i2c`, `local`, `ssh-host`, `i2c-cli`, `spi-cli`,
341
+ `tcp` (TLS), `http`, `nxp,pca9548` mux
342
+ - ✅ Capability model, driver plugin registry, trustworthy retry policy
343
+ - ✅ Agent tool surface: `tool_schemas()` / `tool_catalog()` / `call_tool()`
344
+ - ✅ Human-in-the-loop actuation gate: actuator ops stop for an injectable
345
+ `Approver` (pre-I/O, unbypassable, every decision audited)
346
+ - ✅ Structured observability + `capture()` flight recorder
347
+
348
+ **Designed, in progress — Phase 2:**
349
+
350
+ - 🚧 Async / streaming (`subscribe`, held channels) — [spec](docs/DESIGN%20-%20PHASE%202%20ASYNC.md)
351
+ - 🚧 Actuator watchdog & safe-state (timeouts, auto safe-state on disconnect)
352
+ - 🚧 Route failover for multi-path devices
353
+
354
+ ---
355
+
356
+ ## Documentation
357
+
358
+ - [Architecture & locked decisions](docs/DESIGN%20V2.md)
359
+ - [Phase 1 implementation decisions](docs/DECISIONS%20-%20V2.1.md)
360
+ - [Phase 2 async + watchdog spec](docs/DESIGN%20-%20PHASE%202%20ASYNC.md)
361
+ - Build guides: write a [driver](.claude/skills/shal-build-driver/SKILL.md),
362
+ a [bus](.claude/skills/shal-build-bus/SKILL.md), or a
363
+ [topology](.claude/skills/shal-build-yaml/SKILL.md)
364
+
365
+ ---
366
+
367
+ ## Contributing
368
+
369
+ Contributions welcome — **new drivers and buses especially**. A driver is one
370
+ small class (see [above](#write-a-driver-in-30-seconds)); SHAL discovers it via
371
+ the `shal.drivers` entry point.
372
+
373
+ ```bash
374
+ pip install -e ".[dev]"
375
+ python -m pytest # test suite
376
+ ruff check src tests # lint
377
+ ```
378
+
379
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the full guide.
380
+
381
+ ---
382
+
383
+ ## License
384
+
385
+ [MIT](LICENSE).
@@ -0,0 +1,42 @@
1
+ pyshal-0.1.0.dist-info/licenses/LICENSE,sha256=tdL5yHCYzaznc1F7JtcuLha1ygaVGwEk3XBgL_HOUfs,1074
2
+ shal/__init__.py,sha256=5R7SUSSTGranj_4tGejYo13kU3JiyHviLicpEhwfW88,1711
3
+ shal/approval.py,sha256=5sydRVaTpKqzh-w4e_amPXHZD7nWB7XGEV1Y7tEsxFU,5341
4
+ shal/capabilities.py,sha256=qf4vLhiuCpace1nG7auEW5x-tQW7A3gcls2sihq3JZY,2232
5
+ shal/conformance.py,sha256=JpOcciSo-Os-oyjYEdMIdQVPKiKTU3985SI4JP6NBH0,10514
6
+ shal/driver.py,sha256=AdrQrIfCjUDwHEYA7np-RCPvZh_9gHxmQIfMaNvg_04,15500
7
+ shal/errors.py,sha256=0vreTxdOOq8lHpZ8n5d2S8qsVUdMb2h5W4XtgMrVL6w,2944
8
+ shal/hal.py,sha256=UFLeqgkL9CVCC05d3mpIugJHz9-uS0C5kYlgJPQAVY0,9785
9
+ shal/limits.py,sha256=i4l_7linn0Nptf-JaHoE04Aybp8njgRXOrYkjNHeeug,8806
10
+ shal/loader.py,sha256=RMFuoOlDk3bA1zPrnHI3D06RjWKHZKTZv7YlwdpCWaQ,9890
11
+ shal/log.py,sha256=y1UR2GLnbIQQupHRTrgdyNHys3XPMQ1FqZm6TEhwYAk,2958
12
+ shal/logging.py,sha256=FZsiawuvJJrkABwT1MP5z8mwo4PYckG3BDJsBIRBkm0,4313
13
+ shal/node.py,sha256=UfqFsAkHYrOGCZmrlNCuiOnmwA8WnhKMCyDyt1y0pcc,2503
14
+ shal/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ shal/registry.py,sha256=e10azuG4aqT95AWxpIFqRN7VyU_fScvVRh1B5TUa8oI,8624
16
+ shal/transport.py,sha256=zomnaUkc-2JPhP2k6wxaijQtuXKsoml7E7DeQm5JTDo,3312
17
+ shal/buses/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
18
+ shal/buses/http_bus.py,sha256=EkkbxAUrVJh3-G-9hi4lKYnLJe5U725bV4lGRoLc4X4,3390
19
+ shal/buses/i2c_cli.py,sha256=ZoqNj6Jfgq1IlT_1CvOMy5ppNIZt4Evsrinf93md3IM,3612
20
+ shal/buses/local.py,sha256=Tqv6tiKI_B5wJtwk8BJFKCz74c3SE_-6RjBREL9Nc00,2113
21
+ shal/buses/mux.py,sha256=Yw1xQXD5gNBRAw913ALmH4vcm0zQg4CEF1XhOjwGuNg,3031
22
+ shal/buses/scpi_raw.py,sha256=9VwrMq_-8XxEuYBXzcJutCnJXjyZLWcJdjpQFMh1sls,4694
23
+ shal/buses/sim.py,sha256=9n7L6QeVh_ifEUDLBPlVCQ5aiQLn751sa1zwH1_apEA,8555
24
+ shal/buses/sim_msg.py,sha256=SIHUsn9X2Vnl9ETmPLtfz6QvvDRn00isP8HcMyCBGa8,4072
25
+ shal/buses/sim_scpi.py,sha256=t7hGEZu8K-mu4ZsLGB7OoB0Dr941Dc0VSzzRBeEKwN4,5355
26
+ shal/buses/spi_cli.py,sha256=CBvxbeXge0LzbfRASd4MjgZOiwxQIIb7X1uoo9AMZQ4,2554
27
+ shal/buses/ssh.py,sha256=JWBgu24H5ALYzv7AvAfXHHtj-efJRcasM4mQqJ1Dd6o,3254
28
+ shal/buses/tcp.py,sha256=GuwKtIDGrigtaN7_FSHOugdFJsYRGb4yymtiZUGNX4g,4008
29
+ shal/drivers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
+ shal/drivers/ads1115.py,sha256=9advweCf_rio2WP2tr44gktwA-X4epcHUFzCdKRL7tM,1824
31
+ shal/drivers/ina219.py,sha256=366uXJQlQovQzGhIomByMQ6uSRaKMZfGoxbPPY9wgsM,2109
32
+ shal/drivers/keysight_34461a.py,sha256=4qJmLQfaAbVgW-_OhbhCPrbpfAp-8ITPggUnGbs6m84,1549
33
+ shal/drivers/mcp23017.py,sha256=GnncKWhhK1pv7WjTw14kgMIpWJ79kCcp6tz3DPN-Jww,2064
34
+ shal/drivers/mcp9808.py,sha256=jrQM38T_hm02K7LxujtLxTBlvdUnfOMeScEvqNh0fmE,1423
35
+ shal/drivers/rigol_dp832.py,sha256=0LeJ6l57lAD69YPsKm0TGnykIwdBKaukPbm7mz9YdpM,2327
36
+ shal/drivers/tmp102.py,sha256=7bHigCQeBsf89-vvJzKpDhN7X4CNlCa_1A6c-WBNCOI,1198
37
+ shal/schema/shal-v1.schema.json,sha256=tavFK3gzhvkWBjO-LFC28X6RfzvZqnLE9vjBnj380C0,5083
38
+ pyshal-0.1.0.dist-info/METADATA,sha256=wneLnQ77fVdh9WOZ1whHUiaIiWlWSdkHOU8UFE8gn9s,14056
39
+ pyshal-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
40
+ pyshal-0.1.0.dist-info/entry_points.txt,sha256=ZZAbTDE3HcMCZ2IjLpPXM8EEXlAKe9JSGGIOIVNtH3g,801
41
+ pyshal-0.1.0.dist-info/top_level.txt,sha256=229u67H27LrLF5xo7mG607l1XRGqy4gjYx5dHPtRvXk,5
42
+ pyshal-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,19 @@
1
+ [shal.drivers]
2
+ keysight,34461a = shal.drivers.keysight_34461a:Keysight34461a
3
+ microchip,mcp23017 = shal.drivers.mcp23017:Mcp23017
4
+ microchip,mcp9808 = shal.drivers.mcp9808:Mcp9808
5
+ nxp,pca9548 = shal.buses.mux:Pca9548
6
+ rigol,dp832 = shal.drivers.rigol_dp832:RigolDp832
7
+ shal,http = shal.buses.http_bus:HttpBus
8
+ shal,i2c-cli = shal.buses.i2c_cli:I2cCliBus
9
+ shal,local = shal.buses.local:LocalBus
10
+ shal,scpi-raw = shal.buses.scpi_raw:ScpiRawBus
11
+ shal,sim-i2c = shal.buses.sim:SimI2cBus
12
+ shal,sim-msg = shal.buses.sim_msg:SimMsgBus
13
+ shal,sim-scpi = shal.buses.sim_scpi:SimScpiBus
14
+ shal,spi-cli = shal.buses.spi_cli:SpiCliBus
15
+ shal,ssh-host = shal.buses.ssh:SshBus
16
+ shal,tcp = shal.buses.tcp:TcpBus
17
+ ti,ads1115 = shal.drivers.ads1115:Ads1115
18
+ ti,ina219 = shal.drivers.ina219:Ina219
19
+ ti,tmp102 = shal.drivers.tmp102:Tmp102
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SHAL contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ shal
shal/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """SHAL — System/Software Hardware Abstraction Layer.
2
+
3
+ import shal
4
+ with shal.load("setup.yaml") as hal:
5
+ print(hal.get_device("ambient_temp").read_celsius())
6
+ """
7
+ from . import logging # opt-in observability: shal.logging.{Console,JSON}Formatter, capture
8
+ from .approval import (
9
+ ApprovalRequest,
10
+ Approver,
11
+ AutoApprove,
12
+ CallableApprover,
13
+ ConsoleApprover,
14
+ DenyAll,
15
+ approver,
16
+ get_approver,
17
+ set_approver,
18
+ )
19
+ from .capabilities import (
20
+ ADC,
21
+ DigitalMultimeter,
22
+ GPIOExpander,
23
+ PowerMonitor,
24
+ PowerSupply,
25
+ TemperatureSensor,
26
+ )
27
+ from .driver import Driver, idempotent, op
28
+ from .errors import (
29
+ ApprovalDenied,
30
+ Busy,
31
+ Error,
32
+ Gap,
33
+ HopError,
34
+ HopTimeout,
35
+ LimitError,
36
+ LoadError,
37
+ )
38
+ from .hal import Hal, load
39
+ from .node import Node
40
+ from .registry import catalog, register
41
+ from .transport import (
42
+ ByteTransport,
43
+ CommandTransport,
44
+ Completed,
45
+ MessageTransport,
46
+ Op,
47
+ Read,
48
+ Stream,
49
+ Transport,
50
+ Write,
51
+ )
52
+
53
+ __version__ = "0.1.0"
54
+
55
+ __all__ = [
56
+ "load", "Hal", "Node", "Driver", "idempotent", "op", "register", "catalog", "logging",
57
+ "Approver", "ApprovalRequest", "AutoApprove", "DenyAll", "CallableApprover",
58
+ "ConsoleApprover", "set_approver", "get_approver", "approver",
59
+ "Error", "LoadError", "HopError", "HopTimeout", "LimitError", "ApprovalDenied",
60
+ "Busy", "Gap",
61
+ "Transport", "ByteTransport", "CommandTransport", "MessageTransport",
62
+ "Stream", "Op", "Read", "Write", "Completed",
63
+ "TemperatureSensor", "PowerMonitor", "PowerSupply", "DigitalMultimeter",
64
+ "ADC", "GPIOExpander", "__version__",
65
+ ]
shal/approval.py ADDED
@@ -0,0 +1,143 @@
1
+ """Human-in-the-loop actuation gate (issue #14).
2
+
3
+ SHAL's promise — "it asks permission before it moves" — is enforced HERE, at the
4
+ same capability-wrapper chokepoint where limits and audit already fire. For an op
5
+ whose effective ``side_effect`` is ``"actuator"`` (physical motion), the wrapper
6
+ consults the active :class:`Approver` AFTER the limit check and BEFORE any bus
7
+ I/O. Because the gate lives in the wrapper, NO call path can bypass it — the tool
8
+ surface (``hal.call_tool``) and the raw path (``get_device().method()``) go
9
+ through the exact same enforcement.
10
+
11
+ SHAL ships the *mechanism* and a *safe default* (ask on the terminal when
12
+ interactive; deny when there is no one to ask — a pipe, a cron job, CI). The host
13
+ injects the *decision*:
14
+
15
+ import shal
16
+ shal.set_approver(shal.AutoApprove()) # sim / CI / tests
17
+ with shal.approver(MyNanoClawApprover()): # scoped policy
18
+ ...
19
+
20
+ Order is always **limits -> approval -> I/O**: an impossible call is rejected by
21
+ limits before anyone is asked to approve it. Every decision (allow or deny) is
22
+ written to ``shal.audit`` so runs stay deterministic and replayable.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import sys
27
+ from collections.abc import Callable
28
+ from contextlib import contextmanager
29
+ from contextvars import ContextVar, Token
30
+ from dataclasses import dataclass
31
+ from typing import Protocol, runtime_checkable
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ApprovalRequest:
36
+ """What the wrapper hands an Approver. Immutable, fully self-describing so a
37
+ decision can be made (and logged) without reaching back into framework state."""
38
+ op: str
39
+ path: str
40
+ id: str
41
+ side_effect: str
42
+ params: dict
43
+ txn: str
44
+
45
+
46
+ @runtime_checkable
47
+ class Approver(Protocol):
48
+ """A policy that decides whether a side-effecting op may proceed.
49
+
50
+ Return ``True`` to allow, ``False`` to deny. Raising is treated by the caller
51
+ as a hard failure (not a denial) — return ``False`` to refuse cleanly."""
52
+
53
+ def approve(self, request: ApprovalRequest) -> bool: ...
54
+
55
+
56
+ class AutoApprove:
57
+ """Allow every actuation. For sim, CI, and tests — never production."""
58
+
59
+ def approve(self, request: ApprovalRequest) -> bool:
60
+ return True
61
+
62
+
63
+ class DenyAll:
64
+ """Refuse every actuation. The safest possible policy."""
65
+
66
+ def approve(self, request: ApprovalRequest) -> bool:
67
+ return False
68
+
69
+
70
+ class CallableApprover:
71
+ """Adapt a plain ``fn(request) -> bool`` into an Approver (e.g. a NanoClaw
72
+ callback, a queue handoff, a custom prompt)."""
73
+
74
+ def __init__(self, fn: Callable[[ApprovalRequest], bool]) -> None:
75
+ self._fn = fn
76
+
77
+ def approve(self, request: ApprovalRequest) -> bool:
78
+ return bool(self._fn(request))
79
+
80
+
81
+ class ConsoleApprover:
82
+ """Ask on the terminal. The shipped default.
83
+
84
+ If the input stream is not a TTY (a pipe, cron, CI), there is no one to
85
+ answer, so it DENIES — never block a headless run on an unanswerable prompt.
86
+ Inject :class:`AutoApprove` for those environments.
87
+ """
88
+
89
+ def __init__(self, *, stream=None, prompt: Callable[[str], str] = input) -> None:
90
+ self._stream = stream
91
+ self._prompt = prompt
92
+
93
+ def approve(self, request: ApprovalRequest) -> bool:
94
+ stream = self._stream or sys.stdin
95
+ if not getattr(stream, "isatty", lambda: False)():
96
+ return False # no one to ask -> don't move
97
+ args = ", ".join(f"{k}={v!r}" for k, v in request.params.items())
98
+ who = request.id or request.path
99
+ banner = (f"\n SHAL approval required [{request.side_effect}]\n"
100
+ f" {who}.{request.op}({args})\n"
101
+ f" Allow this actuation? [y/N] ")
102
+ try:
103
+ answer = self._prompt(banner)
104
+ except EOFError:
105
+ return False
106
+ return answer.strip().lower() in ("y", "yes")
107
+
108
+
109
+ # The active policy. The default is safe: prompt when interactive, deny otherwise.
110
+ # (ContextVar default must be immutable per flake8-bugbear; resolve the singleton
111
+ # in get_approver() so an unset context falls back to ConsoleApprover.)
112
+ _DEFAULT: Approver = ConsoleApprover()
113
+ _current: ContextVar[Approver | None] = ContextVar("shal_approver", default=None)
114
+
115
+
116
+ def get_approver() -> Approver:
117
+ """The Approver the wrapper will consult for the current context."""
118
+ return _current.get() or _DEFAULT
119
+
120
+
121
+ def set_approver(approver: Approver) -> Token:
122
+ """Install ``approver`` as the active policy. Returns a token for ``reset``.
123
+
124
+ Note: the policy lives in a :class:`~contextvars.ContextVar`. A newly spawned
125
+ OS thread does NOT inherit the caller's context, so it falls back to the safe
126
+ default (deny-when-headless) until you call ``set_approver`` inside that
127
+ thread. ``asyncio`` tasks created with the running loop DO inherit it."""
128
+ return _current.set(approver)
129
+
130
+
131
+ def reset(token: Token) -> None:
132
+ """Undo a :func:`set_approver`, restoring the previous policy."""
133
+ _current.reset(token)
134
+
135
+
136
+ @contextmanager
137
+ def approver(a: Approver):
138
+ """Scope an Approver to a ``with`` block; the previous policy is restored on exit."""
139
+ token = _current.set(a)
140
+ try:
141
+ yield a
142
+ finally:
143
+ _current.reset(token)
shal/buses/__init__.py ADDED
File without changes