. "$BST_LIB" # BST-TIMEOUT: 1200 # THREE python3 starts and nothing else: a bare # # `import gpiod', a second one whose STDERR is the # # assertion, and the probe, which takes ONE start for # # seventy-odd measurements. Everything else here reads the # # filesystem with shell builtins or greps the probe's # # output. # # # # 400s per start, against the ~60s a python3 start has # # measured at on this arm. The work inside the probe is # # attribute reads and failed opens -- microseconds -- beside # # a start that is tens of seconds before the first line of # # the program runs. The same bound 99-python3-psutil # # carries, for the same three-start shape. # # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the batch verify, and the measured elapsed replaces # # this note in the test plan rather than in this comment, # # which would cost an image build to keep the shipped suite # # byte-identical. # # python3-gpiod 2.1.3 (meta-openembedded/meta-python # recipes-devtools/python/python3-gpiod_2.1.3.bb, `inherit setuptools3-base # python_pep517 ptest pypi', PyPI sdist) on the wasm32-hwjs glibc image -- the # Python bindings for libgpiod v2, over the libgpiod this image already ships # under BRINTOS-1492. # # ============================================================================ # THERE IS NO GPIO CHIP BEHIND THIS MACHINE, AND THE EMPTY ENUMERATION IS THE # ASSERTION. # ============================================================================ # # No /dev/gpiochip*, no gpio-sim, no /sys/bus/gpio -- 99-libgpiod records the # same absence for the C tools and the same reasoning applies here. So the half # of the binding that reads and drives lines cannot run, and this suite does not # pretend otherwise. What it asserts is the half that CAN: that the constants # crossing the ABI carry libgpiod's own values, that "no chip" comes back as the # documented ENOENT rather than as a crash or a wrong errno, and that the # request-config objects build, inspect and reject invalid combinations with no # chip present. # # AN EMPTY ENUMERATION IS AN EMPTY LIST, NOT AN ERROR. This is the reading a # suite written from the ticket's framing would most easily get backwards -- # looking for a diagnostic or a non-zero status and reddening a binding that is # behaving exactly as upstream intends. Both halves are asserted: that nothing # enumerates as a chip, AND that asking produced no error. # # EVERY HARDWARE-DEPENDENT EXPECTATION IS PROBED AT RUNTIME, NEVER HARDCODED. # The probe walks /dev itself and scores every node it finds, so a machine that # later gains a gpiochip reports a different count rather than passing on a # stale expectation. # # ============================================================================ # IT SHIPS A COMPILED EXTENSION, AND THAT IS WHAT THE PORT TURNS ON. # ============================================================================ # # gpiod/__init__.py line 10 is `from . import _ext' -- module scope, guarded by # nothing -- so `import gpiod' requires gpiod/_ext.cpython-312.so to load AND to # run its module init. There is no pure-Python fallback to degrade to, unlike # python3-protobuf whose C accelerator this distro declines on purpose. If the # extension does not come up, nothing in the package is reachable and there is # no degraded mode to assert. # # THE EXTENSION LINKS AND ITS IMPORTS ALL BIND. Measured at recipe scope # (1641 of 1641 tasks, zero ERROR lines): the .deb carries # gpiod/_ext.cpython-312.so, 312200 bytes, a WebAssembly binary module opening # with a `dylink.0' section. tools/brintos_dlopen_census.py over this image's # own provider view -- rt module, libc.so, then the interpreter -- reports # `92 env imports, all bound', so the BRINTOS-2046 wall (an interpreter # exporting no Py* surface) is genuinely behind us: python3.12 now publishes # 1715 symbols and this module needs none the view lacks. # # THE IMPORT WAS PREDICTED TO TRAP AND IT DOES NOT. MEASURED on the batch-2397 # image (verify of 2026-09-08, 99 of 99 cases reported): `import gpiod' comes up # green, the extension loads and runs its module init, and every enum, config- # object and errno case below is a real measurement of the binding rather than a # casualty of a platform gap. # # The recipe phase predicted the opposite, and it is recorded here because the # prediction was WRONG rather than to preserve it: it expected the user-RT park # violation -- `hwjs-user-rt VIOLATION[lane 0]: set-over-set' / `adoption: take # owner mismatch', rc=132, linux-in-the-browser#184 -- that crc32c, lz4, psutil # and schedutils were measured trapping on in one boot. gpiod is the same shape # and does not trap on this runtime (pinned at hardwarejs # d7c499a9528a2996f0a31bcbf32fbefdd5b65d97). Whatever closed that gap for this # module, #184 is not a fact about gpiod, and nothing downstream is excused by # it. # # So there is no expected-red arm in this suite. Every red here is a finding. P=python3-gpiod require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/gpiod DI=$SITE/gpiod-2.1.3.dist-info EXT=$PKGDIR/_ext.cpython-312.so PROBE=$BST_DATA/python3-gpiod-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-gpiod.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # want_one ... : ok iff actual equals one of them. # # For the one errno in this suite that is NOT deterministic. libgpiod's # gpiod_check_gpiochip_device() reaches /dev/null through three different exits # depending on what sysfs this guest has mounted -- ENOTTY if the node is not a # character device, ENODEV once realpath resolves its subsystem to something # other than /sys/bus/gpio, and ENOENT if /sys/dev/char is not populated at all # so realpath itself fails (lib/internal.c). All three are the library correctly # refusing a non-chip; pinning one would assert this guest's sysfs layout rather # than the binding, and would redden on a kernel change that is none of this # port's business. The VALUE is reported either way, so a fourth answer is # visible rather than absorbed. want_one() { _id=$1; _got=$2; shift 2 for _w in "$@"; do if [ "$_got" = "$_w" ]; then bst_ok "$_id"; return; fi done bst_notok "$_id" "got:'$_got' want one of: $*" } # ------------------------------------------------------------- the payload --- # Where the package has to land. The import further down is the functional # statement; these say WHICH copy answered, so a package that imported out of # some other path on sys.path is distinguishable from what this image installed. check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKGDIR" check $P/init-file test -s "$PKGDIR/__init__.py" check $P/chip-file test -s "$PKGDIR/chip.py" check $P/line-file test -s "$PKGDIR/line.py" check $P/line-settings-file test -s "$PKGDIR/line_settings.py" check $P/line-request-file test -s "$PKGDIR/line_request.py" check $P/edge-event-file test -s "$PKGDIR/edge_event.py" check $P/info-event-file test -s "$PKGDIR/info_event.py" check $P/exception-file test -s "$PKGDIR/exception.py" check $P/internal-file test -s "$PKGDIR/internal.py" check $P/version-file test -s "$PKGDIR/version.py" check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -s "$DI/METADATA" check $P/dist-info-record test -s "$DI/RECORD" # THE COMPILED EXTENSION, AS A FILE. This is the whole difference between this # port and the pure-Python python3-* ports beside it, so its presence is # asserted directly rather than inferred from an import that is expected to # fail for an unrelated reason. check $P/cext-present test -s "$EXT" # ...and it is a WebAssembly module, not a host object and not an empty stub. # The first four bytes of a wasm binary are \0asm; `od' is reached through a # pipe, so the read is a forked child's and cannot end this suite (see # bst_local). check_eq $P/cext-is-wasm "0061736d" \ "$(head -c 4 "$EXT" 2>/dev/null | od -An -tx1 | tr -d ' \n')" # A SIDE MODULE, which is what `dylink.0' names. A wasm object built as an # ordinary static input carries no such section and would be exactly as # unloadable as a missing file while passing the magic-number case above. check_out $P/cext-dylink "dylink" \ sh -c "head -c 64 '$EXT' | tr -d '\\000' " # EXACTLY ONE. The inverse of the "no .so anywhere" assertion every pure-Python # sibling makes: a build that quietly stopped producing the extension, or # started producing more, is visible rather than silent. check_eq $P/cext-count "1" \ "$(find "$PKGDIR" -name '*.so' 2>/dev/null | wc -l | tr -d ' ')" # THE UPSTREAM ptest SUITE IS NOT STAGED, AND ITS ABSENCE IS THE CORRECT STATE. # The recipe carries `inherit ptest', but this distro sets DISTRO_FEATURES = "" # so PTEST_ENABLED is 0 and do_compile_ptest/do_install_ptest never run -- no # python3-gpiod-ptest package is produced, which the recipe-scope build # confirms. That suite is built on gpio-sim, a configfs kernel module this # kernel cannot provide (its own run-ptest modprobes configfs first), so there # is nothing here it could have run against even if it shipped. Asserted as an # absence so a future DISTRO_FEATURES change that stages an unrunnable suite is # visible rather than silent. check $P/ptest-not-staged test ! -d /usr/lib/python3-gpiod/ptest # ------------------------------------------------- the machine has no chip --- # THE PREMISE OF EVERY BEHAVIOURAL CASE BELOW, asserted rather than assumed. If # this machine ever gains a GPIO controller these three flip, and the empty- # enumeration cases further down become wrong for a reason the report names. check $P/no-gpiochip-dev sh -c '! ls /dev/gpiochip* >/dev/null 2>&1' check $P/no-sys-bus-gpio test ! -d /sys/bus/gpio check_eq $P/no-gpio-in-proc-devices "" \ "$(grep -i gpio /proc/devices 2>/dev/null | tr -d ' \n')" # ------------------------------------------------------------- the import --- # THE PIVOT. Everything below is downstream of this one answer; the header says # why it was predicted red on linux-in-the-browser#184 and why that prediction # was wrong. check $P/import python3 -c 'import gpiod' # ...and it is QUIET. A module that imports while printing to stderr is a # different result from one that imports cleanly, and a warning here would most # likely be the loader or the runtime saying something the exit status hides. check_eq $P/import-quiet "" \ "$(python3 -c 'import gpiod' 2>&1)" # ---------------------------------------------------------------- the probe --- if [ ! -f "$PROBE" ]; then bst_notok $P/probe-present "$PROBE did not ship" rm -rf "$W" exit 0 fi bst_ok $P/probe-present # ONE interpreter start for everything that follows. bst_probe bounds it and # keeps BOTH streams: a probe that dies early otherwise leaves every case below # reporting got:'' and naming a symptom each, with the actual traceback nowhere. bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" OUT="$W/probe" pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # THE EVIDENCE PLANE. If the probe emitted nothing, every case below is reading # an absent file: each would red with got:'' and the set would read exactly like # a broken library, when what actually happened is that no measurement was # taken. The word `unscorable' is PARSED -- brintos_local_verify re-measures a # suite that prints it on a fresh guest rather than trusting a shared boot's # numbers -- so it goes in this structural check, not a comment. if [ -s "$OUT" ]; then bst_ok $P/probe-output else bst_notok $P/probe-output "the probe emitted nothing -- python3 did not start, or died before its first line; every CASE below is unscorable" fi # THE IMPORT, AS THE PROBE SAW IT. If the extension did not come up, this is the # one line carrying the exception text, and every case after it is that same # cause repeated. Empty is the pass. check_eq $P/probe-import-error "" "$(pv import_error)" # ...and it ran to the END. Every key is emitted in file order, so a probe that # died two thirds of the way through would leave the earlier cases green and the # later ones reading got:'' -- which looks like a partial library rather than a # partial run. These two tell those apart. # # NOTE the count is the FULL-RUN count. On the import-failure arm the probe # emits three keys and exits 0 deliberately, so this case reds along with the # import -- which is correct: a run that never reached the measurements did not # take them. check_eq $P/probe-complete "yes" "$(pv probe_done)" check_eq $P/probe-count "71" "$(pv emitted_count)" # ------------------------------------------------------------- identity --- # The module answered out of the tree this image installed, not a stray copy. check_eq $P/module-file "$PKGDIR/__init__.py" "$(pv module_file)" check_eq $P/is-package "yes" "$(pv is_package)" check_eq $P/version "2.1.3" "$(pv version_attr)" check_eq $P/dist-version "2.1.3" "$(pv dist_version)" # The extension the interpreter actually resolved -- the payload cases above say # the file shipped; this says the running process loaded THAT file. check_eq $P/cext-loaded "$EXT" "$(pv ext_file)" # THE BINDING BOUND TO THE libgpiod THIS IMAGE SHIPS. api_version carries the C # library's own version string, so a mismatch here means the binding linked # against something other than the shipped library -- which is exactly what a # stale sysroot or a vendored copy looks like, and what the recipe's # LINK_SYSTEM_LIBGPIOD=1 exists to prevent. The image ships libgpiod 2.1.3 # (BRINTOS-1492), and the extension carries that same string: 2.1.3 is the only # standalone version token in the built _ext.cpython-312.so, whose DWARF paths # name libgpiod-2.1.3/lib/misc.c -- the translation unit gpiod_api_version() # lives in. # # IT IS A str ATTRIBUTE, NOT A CALL, and this case is the one that measured it: # the first on-target run read got:'' # because the probe called it. ext/module.c publishes it with # PyModule_AddStringConstant(), so the C call happens once at module init and # Python only ever sees the string. check_eq $P/api-version "2.1.3" "$(pv api_version)" # ------------------------------------------------------- the enum tables --- # ASSERTED AGAINST libgpiod's OWN HEADER, read out of this image's sysroot # (usr/include/gpiod.h) rather than off the Python source: these are the numbers # that cross the ABI, and if the extension's constants drifted from the header # every request would be silently wrong while every structure still built. That # is the failure a binding test exists to catch and the one no import check # would see. check_eq $P/enum-value-inactive "0" "$(pv enum_value_inactive)" check_eq $P/enum-value-active "1" "$(pv enum_value_active)" check_eq $P/enum-direction-as-is "1" "$(pv enum_direction_as_is)" check_eq $P/enum-direction-input "2" "$(pv enum_direction_input)" check_eq $P/enum-direction-output "3" "$(pv enum_direction_output)" check_eq $P/enum-bias-as-is "1" "$(pv enum_bias_as_is)" check_eq $P/enum-bias-unknown "2" "$(pv enum_bias_unknown)" check_eq $P/enum-bias-disabled "3" "$(pv enum_bias_disabled)" check_eq $P/enum-bias-pull-up "4" "$(pv enum_bias_pull_up)" check_eq $P/enum-bias-pull-down "5" "$(pv enum_bias_pull_down)" check_eq $P/enum-drive-push-pull "1" "$(pv enum_drive_push_pull)" check_eq $P/enum-drive-open-drain "2" "$(pv enum_drive_open_drain)" check_eq $P/enum-drive-open-source "3" "$(pv enum_drive_open_source)" check_eq $P/enum-edge-none "1" "$(pv enum_edge_none)" check_eq $P/enum-edge-rising "2" "$(pv enum_edge_rising)" check_eq $P/enum-edge-falling "3" "$(pv enum_edge_falling)" check_eq $P/enum-edge-both "4" "$(pv enum_edge_both)" check_eq $P/enum-clock-monotonic "1" "$(pv enum_clock_monotonic)" check_eq $P/enum-clock-realtime "2" "$(pv enum_clock_realtime)" check_eq $P/enum-clock-hte "3" "$(pv enum_clock_hte)" # The tables by SIZE, so a release that ADDED a member is visible rather than # silently unasserted by the per-member cases above. check_eq $P/enumlen-value "2" "$(pv enumlen_value)" check_eq $P/enumlen-direction "3" "$(pv enumlen_direction)" check_eq $P/enumlen-bias "5" "$(pv enumlen_bias)" check_eq $P/enumlen-drive "3" "$(pv enumlen_drive)" check_eq $P/enumlen-edge "4" "$(pv enumlen_edge)" check_eq $P/enumlen-clock "3" "$(pv enumlen_clock)" # ------------------------------------------------- the empty enumeration --- # THE HEADLINE ASSERTION. Not one node in /dev enumerates as a GPIO chip, and # asking produced no error on any of them. Both halves: asserting only the # emptiness would pass against a binding that raised on every node, and # asserting only the absence of errors would pass against one that called # everything a chip. check_eq $P/is-gpiochip-none-true "" "$(pv is_gpiochip_true)" check_eq $P/is-gpiochip-no-errors "" "$(pv is_gpiochip_errors)" # /dev was actually walked. An empty result over an empty list is not evidence, # and this is the case that tells the two apart. check $P/dev-nodes-present sh -c "n=\$(grep '^dev_node_count=' '$OUT' | cut -d= -f2); [ -n \"\$n\" ] && [ \"\$n\" -gt 0 ] 2>/dev/null" # The documented answers for paths that are not chips at all -- False, not an # exception, and not True. check_eq $P/is-gpiochip-devnull "False" "$(pv is_gpiochip_devnull)" check_eq $P/is-gpiochip-missing "False" "$(pv is_gpiochip_missing)" check_eq $P/is-gpiochip-root "False" "$(pv is_gpiochip_root)" check_eq $P/is-gpiochip-empty "False" "$(pv is_gpiochip_empty)" # --------------------------------------------------- the documented ENOENT --- # `Chip('/dev/gpiochip0')' on a machine with no chip. Asserted as a TYPE and an # ERRNO separately: the type alone would pass against a binding that raised # OSError for the wrong reason, and the errno cannot be read without knowing # what carried it. ENOENT is 2 and it is deterministic here -- lstat(2) on a # path that does not exist fails before libgpiod reaches any of its other exits # (lib/internal.c). check_eq $P/chip-missing-oserror "OSError" "$(pv chip_missing_exc)" check_eq $P/chip-missing-enoent "2" "$(pv chip_missing_errno)" # ...and PROMPTLY. A binding that blocked here -- retrying, or waiting on a # device that will never appear -- would still raise eventually and pass both # cases above. The bound is generous because it is wall-clock on a loaded guest; # what it catches is a hang, not a slow path. check $P/chip-missing-prompt sh -c "ms=\$(grep '^chip_missing_elapsed_ms=' '$OUT' | cut -d= -f2); [ -n \"\$ms\" ] && [ \"\$ms\" -lt 5000 ] 2>/dev/null" # A path that does not exist at all takes the same lstat(2) exit: ENOENT again, # and deterministic for the same reason. check_eq $P/chip-nosuch-oserror "OSError" "$(pv chip_nosuch_exc)" check_eq $P/chip-nosuch-enoent "2" "$(pv chip_nosuch_errno)" # A path that EXISTS but is not a chip is the interesting one, and its errno is # the one thing here this suite deliberately does not pin -- see want_one above. check_eq $P/chip-devnull-oserror "OSError" "$(pv chip_devnull_exc)" want_one $P/chip-devnull-errno "$(pv chip_devnull_errno)" 2 19 25 # --------------------------------------- the config objects, with no chip --- # THE HALF OF THE BINDING THAT NEEDS NO HARDWARE AT ALL. LineSettings is a # dataclass over the extension's constants and _line_settings_to_ext() is the # conversion that actually crosses into C, so exercising it proves the # structures line up independently of whether any chip could ever be opened. check_eq $P/settings-default-direction "Direction.AS_IS" "$(pv linesettings_default_direction)" check_eq $P/settings-default-edge "Edge.NONE" "$(pv linesettings_default_edge)" check_eq $P/settings-default-bias "Bias.AS_IS" "$(pv linesettings_default_bias)" check_eq $P/settings-default-drive "Drive.PUSH_PULL" "$(pv linesettings_default_drive)" check_eq $P/settings-default-clock "Clock.MONOTONIC" "$(pv linesettings_default_clock)" check_eq $P/settings-default-active-low "False" "$(pv linesettings_default_active_low)" check_eq $P/settings-default-output-value "Value.INACTIVE" "$(pv linesettings_default_output_value)" check_eq $P/settings-custom-direction "Direction.OUTPUT" "$(pv linesettings_custom_direction)" check_eq $P/settings-custom-edge "Edge.BOTH" "$(pv linesettings_custom_edge)" # upstream's own __repr__, which it overrides precisely because the dataclass # default renders enum members unusably. A release that changed the format is # visible here. check_eq $P/settings-default-repr \ "LineSettings(direction=Direction.AS_IS, edge_detection=Edge.NONE bias=Bias.AS_IS drive=Drive.PUSH_PULL active_low=False debounce_period=datetime.timedelta(0) event_clock=Clock.MONOTONIC output_value=Value.INACTIVE)" \ "$(pv linesettings_default_repr)" # THE CONVERSION INTO C. Where a structure-layout mismatch between the binding # and the shipped libgpiod would surface, with no chip and no line involved. check_eq $P/ext-settings-default "LineSettings" "$(pv ext_linesettings_default)" check_eq $P/ext-settings-output "LineSettings" "$(pv ext_linesettings_output)" # The debounce period is the one field that is not a plain enum -- it crosses as # microseconds derived from a timedelta -- so it is where a units error shows. check_eq $P/ext-settings-debounce "LineSettings" "$(pv ext_linesettings_debounce)" # REJECTED ON AN INVALID COMBINATION. An enum member is the only thing these # fields accept; a bare int or a string must fail the conversion rather than be # coerced, because a binding that accepted one would build a request whose # settings are silently not what was asked for. The rejection happens in # _line_settings_to_ext's `.value' access, so the exact exception is upstream's # behaviour rather than this arm's. check_eq $P/ext-settings-reject-int "" \ "$(pv ext_linesettings_badenum)" check_eq $P/ext-settings-reject-str "" \ "$(pv ext_linesettings_badtype)" # The request path with no chip fails the OPEN, not the argument handling, so it # takes the same ENOENT arm as Chip() above. A binding that reddened here with # a TypeError would mean the config mapping never reached the library. check_eq $P/request-lines-oserror "OSError" "$(pv request_lines_exc)" check_eq $P/request-lines-enoent "2" "$(pv request_lines_errno)" # ------------------------------------------------------ the exception classes --- # Pure Python, needing neither a chip nor the extension's state -- so these are # the cases that would still be green if the hardware half were entirely absent, # and the ones that isolate a packaging problem from a runtime one. check_eq $P/exc-chipclosed-msg "I/O operation on closed chip" "$(pv exc_chipclosed_msg)" check_eq $P/exc-requestreleased-msg "GPIO lines have been released" "$(pv exc_requestreleased_msg)" check_eq $P/exc-chipclosed-is-exception "yes" "$(pv exc_chipclosed_is_exception)" # ----------------------------------------------------------------- the surface --- # The public names, sorted. A release that added or dropped one is visible here # rather than in a behavioural case that would fail for a reason nobody could # place. check_eq $P/line-all "Bias,Clock,Direction,Drive,Edge,Value" "$(pv line_all)" check_eq $P/toplevel-names \ "Chip,ChipClosedError,ChipInfo,EdgeEvent,InfoEvent,LineRequest,LineSettings,RequestReleasedError,api_version,is_gpiochip_device,request_lines" \ "$(pv toplevel_names)" rm -rf "$W"