diff --git a/docs/writing-tests.md b/docs/writing-tests.md new file mode 100644 index 0000000..0912087 --- /dev/null +++ b/docs/writing-tests.md @@ -0,0 +1,95 @@ +# Writing tests against scripted peers + +You have firmware and a scenario with scripted peers (a GPS on a UART, an IMU on +SPI, a barometer on I2C). This guide shows how to turn that into a pytest suite +that asserts on what the peers saw and what the firmware did with it. The worked +sample is [`examples/tests/test_peers.py`](../examples/tests/test_peers.py), +which drives the peers in [`examples/peers/`](../examples/peers/). It is a +trimmed copy of a suite written for a flight controller: five tests here, eight +in the original, one run of 12 virtual seconds (about 30 s of wall time there). + +Peer and `scenario.yaml` reference (plain paths, on the Simantic site): +/docs/reference/peers, /docs/reference/scenario-yaml. The session methods used +below are in [session-api.md](session-api.md). + +## Shape of a suite + +1. **One simulation per module.** Booting and running firmware is the expensive + part; asserting is free. Run once in a `scope="module"` fixture, collect + everything you will want (decoded output, bus frames), and let each test + look at a different slice. +2. **Assert on the wire, not on the firmware's own claims.** Count frames per + peer (`Sim.frames()`, each record has `label` = the part name, `t`, `data`, + `miso`, ...) and decode the firmware's real output (here MAVLink read with + `Sim.read_uart_bytes()`, which is byte-accurate; `read_uart()` is not). +3. **Expected values live in the peer's constants.** The GPS peer reports a 3D + fix at a fixed position, the IMU peer tilts the board by `PITCH_TRIM_DEG`. + The test asserts the firmware reports what the peer put on the wire, and its + comment names the constant. Change a peer, update one number. +4. **A known firmware bug is a strict `xfail`.** It documents the bug with a + reason, keeps the suite green, and turns red the day someone fixes the bug + (XPASS strict), prompting you to delete the marker. + +## The shared run + +```python +@pytest.fixture(scope="module") +def run(): + scenario = yaml.safe_load((PEERS / "scenario.yaml").read_text()) + scenario["machines"]["dut"]["elf"] = str(Path(elf).resolve()) + with Sim(scenario=scenario, machine="dut", uart=MAVLINK_UART, cwd=PEERS) as s: + s.run_for(RUN_SECONDS) # virtual seconds + msgs = list(mavlink_v2(s.read_uart_bytes(from_start=True))) + bus = Counter(f["label"] for f in s.frames(from_start=True)) + return msgs, bus +``` + +- `Sim(scenario=...)` takes the same dict shape as `sim --scenario`'s YAML; + `machine=` picks the machine whose UART `uart=` names. `cwd=` is the base for + relative `elf:` and `script:` paths. +- Peers (`parts:`) need the default `renode` engine. The pytest plugin's + `--sim-backend=renode|rust|both` option chooses the engine for the `sim` + fixture, and `simantic.run_scenario` (a one-shot Rust-engine run) rejects + `parts:` with `NotSupported`. That is why the sample builds `Sim` itself. +- The `sim` fixture is function-scoped (a factory that closes each machine when + its test ends). For a module-wide run, as here, construct `Sim` in your own + module-scoped fixture, as above, and close it with the `with` block. +- The sample skips when `FIRMWARE` is unset: the firmware is yours to supply. + +## The assertions + +```python +def test_gps_fix_reaches_mavlink(run): + msgs, _ = run + assert last(msgs, GPS_RAW_INT)[28] >= 3 # gps_peer reports a 3D fix + +def test_baro_is_read_over_i2c(run): + _, bus = run + assert bus["baro"] > 1000 # frames the baro peer saw + +@pytest.mark.xfail(strict=True, reason="known firmware bug: ...") +def test_baro_reaches_scaled_pressure(run): + ... +``` + +Frames are timestamped in virtual time, so a rate is a subtraction between +consecutive frames and an ordering is a comparison (see the README's "Test what +went over the wire"). Prefer `expect()` over polling loops on the Renode engine, +where each Python/engine hand-off costs a few hundred microseconds. + +## Running it + +``` +pip install simantic pytest +FIRMWARE=/path/to/firmware.elf pytest examples/tests +``` + +If a test fails, read the peer's own log first: peers report with `ctx.info` / +`ctx.warning`; pass `show_logs=True` to `Sim`, or read `s.logs()`, to see +the simulator's log. + +## Things this sample does not do + +- It does not check MAVLink CRCs (the original does, with a small helper). +- Peers are plant-free constants: a fixed fix, a stationary board. Closing a + loop against a physics model is a different job. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1e2dd37 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,16 @@ +# Examples + +| File | What it shows | +| --- | --- | +| `step_and_peek.py` | Plain script, no pytest: boot a board, `expect()` a line, read memory, step time. `python step_and_peek.py fw.elf STM32F401RE` | +| `parallel_sweep.py` | Run the same firmware N times in parallel and collect when each reaches a line. | +| `peers/gps_peer.py` | u-blox UBX GPS on a UART: ACKs CFG-VALSET, streams NAV-PVT at the rate the firmware programs. | +| `peers/icm42688_peer.py` | TDK ICM-42688-P IMU on SPI: registers, FIFO, ODR from the firmware's config. | +| `peers/icp20100_peer.py` | TDK ICP-20100 barometer on I2C: register file and FIFO. | +| `peers/scenario.yaml` | Wires the three peers to a machine. Needs your own `firmware.elf`; see the header. | +| `tests/test_peers.py` | pytest suite over those peers: one shared run per module, strict `xfail`. See `docs/writing-tests.md`. | + +No firmware is shipped. The peers hold their expected values as constants at +the top of each file; the tests read those same numbers. + +Peer and `scenario.yaml` reference: /docs/reference/peers, /docs/reference/scenario-yaml diff --git a/examples/peers/gps_peer.py b/examples/peers/gps_peer.py new file mode 100644 index 0000000..d0c9831 --- /dev/null +++ b/examples/peers/gps_peer.py @@ -0,0 +1,93 @@ +# u-blox M9/M10-class UBX GPS as a scripted UART peer. +# Protocol: u-blox UBX framing (sync B5 62, class, id, length LE16, payload, +# 8-bit Fletcher checksum over class..payload). Known-good vector: +# CFG-VALSET 06 8A 09 00 00 01 00 00 07 00 91 20 01 -> ckA=53 ckB=48 +# Behaviour: ACK every CFG-VALSET, read the measurement rate and nav ratio the +# firmware programs (CFG-RATE-MEAS/NAV), then stream a static 3D-fix NAV-PVT at +# exactly that period. Only the modern CFG-VALSET path is modelled. +import struct + +SYNC = b"\xb5\x62" +CLASS_NAV, CLASS_ACK, CLASS_CFG = 0x01, 0x05, 0x06 +ID_NAV_PVT, ID_ACK_ACK, ID_CFG_VALSET = 0x07, 0x01, 0x8A +KEY_RATE_MEAS, KEY_RATE_NAV, KEY_MSGOUT_NAV_PVT_UART1 = 0x30210001, 0x30210002, 0x20910007 +DEFAULT_RATE_MEAS_MS, DEFAULT_RATE_NAV = 1000, 1 # u-blox power-on defaults + +LAT_DEG, LON_DEG, ALT_M, NUM_SV = 43.4723, -80.5449, 334.0, 11 # static fix + + +def checksum(body: bytes) -> bytes: + a = b = 0 + for x in body: + a = (a + x) & 0xFF + b = (b + a) & 0xFF + return bytes((a, b)) + + +def frame(cls: int, mid: int, payload: bytes = b"") -> bytes: + body = bytes((cls, mid)) + struct.pack(" bytes: + p = bytearray(92) + struct.pack_into("= 2 and self.rx[:2] != SYNC: + del self.rx[0] + if len(self.rx) < 8: + return + total = 8 + (self.rx[4] | self.rx[5] << 8) + if len(self.rx) < total: + return + raw = bytes(self.rx[:total]) + del self.rx[:total] + self._frame(raw) + + def _frame(self, raw): + body = raw[2:-2] + if checksum(body) != raw[-2:]: + self.ctx.warning(f"bad checksum, dropping {raw.hex()}") + return + if body[:2] == bytes((CLASS_CFG, ID_CFG_VALSET)): + # payload: version layers reserved(2) key(4) value(<=4); body[4:] is the payload + key = struct.unpack_from(" ACK-ACK") + if key == KEY_MSGOUT_NAV_PVT_UART1: + self.pvt_on = val == 1 + elif key == KEY_RATE_MEAS: + self.rate_meas = val + elif key == KEY_RATE_NAV: + self.rate_nav = val + self._maybe_stream() + + def _maybe_stream(self): + if self.streaming or not self.pvt_on: + return + self.streaming = True + meas = self.rate_meas or DEFAULT_RATE_MEAS_MS + nav = self.rate_nav or DEFAULT_RATE_NAV + self.ctx.info(f"NAV-PVT every {meas * nav} ms (as programmed by the firmware)") + self.ctx.schedule_periodic(meas * nav * 1000) + + def on_timer(self): + self.itow_ms += self.rate_meas or DEFAULT_RATE_MEAS_MS + self.ctx.send(nav_pvt(self.itow_ms)) diff --git a/examples/peers/icm42688_peer.py b/examples/peers/icm42688_peer.py new file mode 100644 index 0000000..6342e4c --- /dev/null +++ b/examples/peers/icm42688_peer.py @@ -0,0 +1,131 @@ +# TDK ICM-42688-P IMU as a scripted SPI peer. scenario.yaml lists one +# `spi-device` part per chip select with this script. +# Framing: the multiplexer calls on_finish when the part's CS goes high. +# Beat 0 is the register byte (bit7 = read), beats 1.. are data. +# +# Timing: once PWR_MGMT0 turns gyro and accel on, the part produces one +# accel+gyro sample every 1/ODR, ODR from the GYRO_CONFIG0 code the firmware +# writes (this peer assumes ACCEL_CONFIG0 carries the same code). In stream-to-FIFO +# mode each sample becomes a 16-byte packet; FIFO_COUNT (in records, the +# firmware's INTF_CONFIG0 setting) and FIFO_DATA follow those ticks, and each +# packet's 16-bit timestamp is the sample time in 1 us units. +# Source: TDK ICM-42688-P datasheet DS-000347 rev 1.2. +import math +import struct + +# The board sits stationary, nose up PITCH_TRIM_DEG. Axis signs are the +# firmware's convention: adjust them until your attitude estimate reads as +# expected (with the author's firmware: roll 0, pitch -10 deg). +PITCH_TRIM_DEG = 10.0 + +ODR_HZ = {1: 32000, 2: 16000, 3: 8000, 4: 4000, 5: 2000, 6: 1000, 7: 200, 8: 100, + 9: 50, 10: 25, 11: 12.5, 15: 500} # GYRO_ODR code: DS-000347 5.6 (reset code 6 = 1 kHz) +STARTUP_US = 30_000 # gyro start-up, enable -> drive ready, typ: DS-000347 Table 1 (accel is 10 ms, Table 2) +FIFO_PACKETS = (2048 - 16) // 16 # 2 KB FIFO less one packet reserved: DS-000347 6.3 +# MODELED SIMPLIFICATION: a full stream-mode FIFO keeps the newest packets +# (DS-000347 rev 1.2 does not state which end is dropped). + +BANK_SEL, WHO_AM_I, DEVICE_CONFIG, GYRO_DATA_X1 = 0x76, 0x75, 0x11, 0x25 +FIFO_CONFIG, FIFO_COUNTH, FIFO_DATA, SIGNAL_PATH_RESET = 0x16, 0x2E, 0x30, 0x4B +PWR_MGMT0, GYRO_CONFIG0, ACCEL_CONFIG0 = 0x4E, 0x4F, 0x50 + + +class Slave: + def __init__(self, ctx): + self.ctx = ctx + self.beat = 0 + self.reg = 0 + self.read = False + self.out = b"" + self._reset() + + def _reset(self): + self.bank = 0 + self.regs = {GYRO_CONFIG0: 0x06, ACCEL_CONFIG0: 0x06} # bank-0 reset values: DS-000347 section 14 + self.t_on = None # time of the first sample; None while sensors are off + self.next = 0 # index of the next sample the FIFO hands out + + def _period_us(self): + return 1e6 / ODR_HZ.get(self.regs[GYRO_CONFIG0] & 0x0F, 1000) + + def _produced(self): + if self.t_on is None or self.ctx.now_us < self.t_on: + return 0 + return int((self.ctx.now_us - self.t_on) // self._period_us()) + 1 + + def _fifo_count(self): + if not self.regs.get(FIFO_CONFIG, 0) & 0xC0: # bypass mode: FIFO unused + return 0 + n = self._produced() + self.next = max(self.next, n - FIFO_PACKETS) + return n - self.next + + def _gyro_raw(self): + lsb_dps = 16.4 * (1 << ((self.regs[GYRO_CONFIG0] >> 5) & 7)) # GYRO_FS_SEL, 16.4 LSB/dps at +-2000: DS-000347 Table 1, 5.6 + return tuple(int(g * lsb_dps) for g in (0.0, 0.0, 0.0)) # stationary + + def _packet(self): + if self._fifo_count() == 0: + return bytes([0x80]) + bytes(15) # HEADER_MSG: FIFO empty (DS-000347 6.2) + ts = int(self.t_on + self.next * self._period_us()) + self.next += 1 + lsb_g = 2048 << ((self.regs[ACCEL_CONFIG0] >> 5) & 3) # ACCEL_FS_SEL: +-16/8/4/2 g (DS-000347 5.6) + theta = math.radians(PITCH_TRIM_DEG) + ax, ay, az = math.sin(theta), 0.0, math.cos(theta) # gravity in the body frame, g + accel = (int(lsb_g * ax), int(lsb_g * ay), int(lsb_g * az)) + gyro = self._gyro_raw() + # Packet 3: header accel|gyro|ODR timestamp (0x68), accel, gyro, temp, timestamp (DS-000347 6.1) + return bytes([0x68]) + struct.pack(">6h", *accel, *gyro) + b"\x00" + struct.pack(">H", ts & 0xFFFF) + + def on_transmit(self, b): + i = self.beat + self.beat += 1 + if i == 0: + self.read = bool(b & 0x80) + self.reg = b & 0x7F + if self.read: + self.out = self._read(self.reg) + return 0x00 + if self.read: + if self.reg == FIFO_DATA and self.bank == 0: # packets pop as they are clocked out + if (i - 1) % 16 == 0: + self.out = self._packet() + return self.out[(i - 1) % 16] + return self.out[i - 1] if i - 1 < len(self.out) else 0x00 + self._write(self.reg + i - 1, b) + return 0x00 + + def _write(self, reg, v): + if reg == BANK_SEL: + self.bank = v & 7 + return + if self.bank != 0: + return + if reg == DEVICE_CONFIG and v & 0x01: # soft reset + self._reset() + return + if reg == SIGNAL_PATH_RESET and v & 0x02: # FIFO_FLUSH + self.next = self._produced() + elif reg == PWR_MGMT0: # GYRO_MODE 11 = LN, ACCEL_MODE 1x = LP/LN + on = (v >> 2) & 3 == 3 and v & 3 >= 2 + if not on: + self.t_on = None + elif self.t_on is None: + self.t_on, self.next = self.ctx.now_us + STARTUP_US, 0 + self.regs[reg] = v + + def _read(self, reg): + if reg == BANK_SEL: + return bytes([self.bank]) + if reg == WHO_AM_I: + return b"\x47" + if reg == GYRO_DATA_X1: + return struct.pack(">hhh", *self._gyro_raw()) + if reg == FIFO_COUNTH: + return struct.pack(">H", self._fifo_count()) # records, big endian (INTF_CONFIG0 = 0xF0) + if reg == FIFO_DATA: + return b"" + return bytes([self.regs.get(reg, 0x00)]) * 2 + + def on_finish(self): + self.beat = 0 diff --git a/examples/peers/icp20100_peer.py b/examples/peers/icp20100_peer.py new file mode 100644 index 0000000..f67f059 --- /dev/null +++ b/examples/peers/icp20100_peer.py @@ -0,0 +1,90 @@ +# TDK ICP-20100 barometer as a scripted I2C peer, 7-bit 0x63. Register file, +# 8-bit pointer. Reports revision B (VERSION 0xB2) so a driver's init goes +# straight to the FIR warm-up. +# Data: 20-bit two's complement, P = raw*40/2^17 + 70 kPa, T = raw*65/2^18 + 25 C. +# STM32F7_I2C hands over one NBYTES chunk per on_write and calls on_finish at +# STOP, so a HAL Mem_Write arrives as [reg] then [data]; accumulate until the +# transaction ends, byte 0 is the pointer. +# +# Timing: while MODE_SELECT.MEAS_MODE=1 the part writes one pressure/temperature +# pair to its FIFO every 1/ODR, ODR set by the MEAS_CONFIG mode the firmware +# writes. A full FIFO drops new pairs; an empty one reads 0x00. +# Source: TDK ICP-20100 datasheet DS-000416 rev 1.3. +# +P_KPA, T_C = 100.4, 22.0 # the world: ~80 m ASL, room temperature + +MODE_ODR_HZ = (25, 120, 40, 2, 25) # MEAS_CONFIG mode 0..4: DS-000416 2.2 Table 2 (mode 4 = factory default config) +FIFO_DEPTH = 16 # pairs: DS-000416 section 7 +# MODELED SIMPLIFICATION: the first pair lands one ODR period after MEAS_MODE +# goes to 1; the datasheet gives no separate first-sample latency. + +VERSION, DEVICE_STATUS, MODE_SELECT, FIFO_FILL, PRESS_DATA = 0xD3, 0xCD, 0xC0, 0xC4, 0xFA + + +def raw20(v): + return v & 0xFFFFF + + +class Slave: + def __init__(self, ctx): + self.ctx = ctx + self.ptr = 0 + self.buf = b"" + self.mode = 0 + self.fill = 0 + self.t0 = 0 # start of the current measurement run + self.ticks = 0 # ODR ticks of this run already pushed into the FIFO + + def _sample(self): + p = raw20(int((P_KPA - 70.0) * 131072 / 40.0)) + t = raw20(int((T_C - 25.0) * 262144 / 65.0)) + return bytes([p & 0xFF, (p >> 8) & 0xFF, (p >> 16) & 0x0F, + t & 0xFF, (t >> 8) & 0xFF, (t >> 16) & 0x0F]) + + def _advance(self): + # Bring the FIFO up to now: one pair per elapsed ODR tick, none past full. + if not self.mode & 0x08: + return + period_us = 1_000_000 // MODE_ODR_HZ[min(self.mode >> 5, 4)] + ticks = (self.ctx.now_us - self.t0) // period_us + self.fill = min(self.fill + ticks - self.ticks, FIFO_DEPTH) + self.ticks = ticks + + def on_write(self, data): + self.buf += bytes(data) + if not self.buf: + return + self.ptr = self.buf[0] + if len(self.buf) < 2: + return + v = self.buf[1] + self._advance() + if self.ptr == MODE_SELECT: + if v & 0x08 and not self.mode & 0x08: + self.t0, self.ticks = self.ctx.now_us, 0 + self.mode = v + self.ctx.info(f"MODE_SELECT=0x{v:02x} ({f'mode {v >> 5}, measuring' if v & 0x08 else 'standby'})") + elif self.ptr == FIFO_FILL and v & 0x80: + self.fill = 0 + + def on_finish(self): + self.buf = b"" + + def on_read(self, count): + self.buf = b"" + self._advance() + r = self.ptr + if r == VERSION: + out = b"\xB2" + elif r == DEVICE_STATUS: + out = b"\x01" # MODE_SYNC: MODE_SELECT writable + elif r == FIFO_FILL: # DS-000416 13.24: EMPTY bit 6, FULL bit 5, level 4:0 + out = bytes([self.fill | (0x40 if self.fill == 0 else 0) | (0x20 if self.fill == FIFO_DEPTH else 0)]) + elif r == PRESS_DATA: # each 6-byte pair read pops one FIFO entry (DS-000416 6.7) + out = b"" + for _ in range((count + 5) // 6): + out += self._sample() if self.fill else bytes(6) + self.fill = max(self.fill - 1, 0) + else: + out = b"\x00" + return (out + bytes(count))[:count] diff --git a/examples/peers/scenario.yaml b/examples/peers/scenario.yaml new file mode 100644 index 0000000..cd61924 --- /dev/null +++ b/examples/peers/scenario.yaml @@ -0,0 +1,16 @@ +# Runs the three example peers against YOUR firmware. Run from this directory: +# sim --scenario scenario.yaml --timeout 12 --frames frames.txt +# `firmware.elf` is not shipped: supply an STM32H753 image that configures a +# u-blox GPS on USART6 (230400), an ICM-42688-P on SPI1 (CS PC4) and an +# ICP-20100 on I2C2 (0x63). Change mcu, buses, pins and baud to match yours. +machines: + dut: + mcu: STM32H753IITX + elf: firmware.elf + parts: + - { name: gps, type: uart-device, baud: 230400, script: gps_peer.py } + - { name: imu, type: spi-device, bus: spi1, cs: PC4, script: icm42688_peer.py } + - { name: baro, type: i2c-device, bus: i2c2, address: 0x63, script: icp20100_peer.py } +media: + - type: uart + connect: [dut.usart6, dut.gps] diff --git a/examples/tests/test_peers.py b/examples/tests/test_peers.py new file mode 100644 index 0000000..eb8eec2 --- /dev/null +++ b/examples/tests/test_peers.py @@ -0,0 +1,87 @@ +"""Peer-test sample: boot firmware against the scripted peers in ../peers and +assert on what came out, from one simulation shared by every test in the module. + + pip install simantic pytest + FIRMWARE=/path/to/firmware.elf pytest examples/tests + +You supply the firmware (it must stream MAVLink v2 on MAVLINK_UART and talk to +the three peers wired in ../peers/scenario.yaml). Expected values are the +constants at the top of each peer; change a peer and update the number here. +""" +import os +import struct +from collections import Counter +from pathlib import Path + +import pytest +import yaml + +from simantic import Sim + +PEERS = Path(__file__).resolve().parent.parent / "peers" +MAVLINK_UART = "usart1" # the UART your firmware streams MAVLink on +RUN_SECONDS = 12.0 # virtual seconds +HEARTBEAT, GPS_RAW_INT, SCALED_PRESSURE, ATTITUDE = 0, 24, 29, 30 + + +def mavlink_v2(data: bytes): + """Yield (msgid, payload) for each MAVLink v2 frame. CRC is not checked.""" + i = 0 + while i + 12 <= len(data): + if data[i] != 0xFD: + i += 1 + continue + n = data[i + 1] + if i + 12 + n > len(data): + break + yield int.from_bytes(data[i + 7:i + 10], "little"), data[i + 10:i + 10 + n] + i += 12 + n + + +@pytest.fixture(scope="module") +def run(): + elf = os.environ.get("FIRMWARE") + if not elf or not Path(elf).exists(): + pytest.skip("set FIRMWARE=/path/to/firmware.elf") + scenario = yaml.safe_load((PEERS / "scenario.yaml").read_text()) + scenario["machines"]["dut"]["elf"] = str(Path(elf).resolve()) + # cwd resolves the peers' `script:` paths; parts need the default renode backend. + with Sim(scenario=scenario, machine="dut", uart=MAVLINK_UART, cwd=PEERS) as s: + s.run_for(RUN_SECONDS) + msgs = list(mavlink_v2(s.read_uart_bytes(from_start=True))) + bus = Counter(f["label"] for f in s.frames(from_start=True)) + return msgs, bus + + +def last(msgs, mid): + p = [p for m, p in msgs if m == mid][-1] + return p.ljust(64, b"\0") # MAVLink v2 trims trailing zero bytes + + +def test_mavlink_stream_has_the_expected_messages(run): + msgs, _ = run + assert {m for m, _ in msgs} >= {HEARTBEAT, GPS_RAW_INT, SCALED_PRESSURE, ATTITUDE} + + +def test_gps_fix_reaches_mavlink(run): + msgs, _ = run + assert last(msgs, GPS_RAW_INT)[28] >= 3 # fix_type: gps_peer reports a 3D fix + + +def test_imu_is_polled_and_attitude_follows(run): + msgs, bus = run + assert bus["imu"] > 100 + pitch_deg = struct.unpack_from(" 1000 + + +@pytest.mark.xfail(strict=True, reason="known firmware bug: SCALED_PRESSURE is sent from an " + "uninitialised struct on ticks where no baro sample is ready") +def test_baro_reaches_scaled_pressure(run): + msgs, _ = run + assert 1000 < struct.unpack_from("=8"] [tool.hatch.build.targets.wheel] packages = ["src/simantic"] + +# Ship the examples in the wheel (under simantic/examples) and the sdist. +[tool.hatch.build.targets.wheel.force-include] +"examples" = "simantic/examples" + +[tool.hatch.build.targets.sdist] +include = ["src", "examples", "docs", "tests", "README.md", "LICENSE", "pyproject.toml"] diff --git a/src/simantic/py.typed b/src/simantic/py.typed new file mode 100644 index 0000000..e69de29