. "$BST_LIB" # BST-TIMEOUT: 1200 # THREE python3 starts and nothing else. # # # # The three are: a bare `import', a second one whose # # STDERR is the assertion (`import-quiet'), and the probe, # # which takes ONE start for all 178 measurements. 400s per # # start is the budget the neighbouring pure-Python python3 # # suites use on this arm. # # # # NOTHING HERE FORKS. Unlike 99-python3-editor next door, # # which spends most of its bound on execve(2), this package # # owns no transport and runs no program: every case is a # # dict or a string built and taken apart in the probe's own # # interpreter. So the interpreter start IS the whole cost, # # and the bound is three of them. # # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the batch verify. 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-jsonrpcclient 4.0.3 (meta-openembedded meta-python/ # recipes-devtools/python/python3-jsonrpcclient_4.0.3.bb -- `inherit # python_setuptools_build_meta', a GIT fetch pinned at SRCREV # e5dd11736925a9a8e463fc9420eab95235f181e3, not a PyPI sdist) on the # wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE IS, AND THEREFORE WHAT HAS TO BE TESTED. # ============================================================================ # # jsonrpcclient BUILDS JSON-RPC 2.0 REQUEST OBJECTS AND TAKES RESPONSE OBJECTS # APART. That is the whole of it. `request', `notification' and their # `*_json' spellings produce the request; `parse' and `parse_json' turn a # response into an `Ok' or an `Error'. # # IT OWNS NO TRANSPORT. There is no socket, no http client and no session # anywhere in the six modules -- `transport-modules' and `socket-imported' # assert that as a measured absence rather than as a reading of the source. # The caller supplies whatever moves the bytes, which is why every case in this # file is a string in and a string out: NOTHING HERE REACHES A DEVICE, A PEER # OR A TERMINAL, and that is what lets this port close on its suite with no # BRINTOS-780 follow-on. # # ============================================================================ # FOUR OF THE TICKET'S EXPECTATIONS ARE WRONG, AND THIS SUITE RECORDS WHAT IS # ACTUALLY THERE. # ============================================================================ # # BRINTOS-2292 asks that "a response missing `jsonrpc', one carrying both # `result' and `error', and one whose `id' does not match any request are each # asserted to produce the documented failure rather than a partly-populated # result". NONE OF THE THREE FAILS. `responses.to_response' is nine lines and # reads exactly three keys: # # Ok(response["result"], response["id"]) if "result" in response # else Error(response["error"]["code"], response["error"]["message"], # response["error"].get("data"), response["id"]) # # so: # # * `jsonrpc' IS NEVER LOOKED AT. A response without it parses as an ordinary # Ok, and so does one declaring `"jsonrpc": "1.0"'. `bad-no-jsonrpc' and # `bad-wrong-version'. # * BOTH `result' AND `error' IS AN Ok, AND THE ERROR IS DISCARDED WITHOUT A # WORD -- `"result" in response' is tested first and wins. `bad-both'. The # mirror image, `"result": null' beside a real error, goes the same way, # because the test is on the KEY and not on the value: `bad-both-null-result'. # * AN UNKNOWN `id' HAS NOTHING TO BE UNKNOWN TO. The package keeps no # registry of outstanding requests -- it holds no state at all between a # request and a response -- so correlation is entirely the caller's job and # any id whatsoever comes back unremarked. `bad-unknown-id'. # # Only the fourth, text that is not JSON, fails as the ticket expects, and it # fails in `json.loads' rather than in this package: `bad-not-json' pins # JSONDecodeError and `bad-not-json-valueerror' pins that a caller catching # ValueError catches it. # # What DOES raise is a KeyError out of the three `[]' lookups -- no `id', no # `result' and no `error', an `error' missing `code' or `message' -- and a # TypeError where `error' is not subscriptable. Seven `bad-*' cases below. # # ============================================================================ # THREE THINGS A CALLER WILL GET WRONG. THESE ARE THE POINT OF THE SUITE. # ============================================================================ # # `parse' OF A BATCH RETURNS A LAZY `map', NOT A LIST. `parse' is # `map(to_response, deserialized)' for a list input, so # `len()' RAISES TypeError and the sequence is CONSUMED ONCE: # a second pass over the same object yields nothing at all, # silently, and a caller who logs the batch and then handles # it handles an empty one. `batch-parse-type', # `batch-len-raises' and `batch-second-pass' are the three # halves of that, and it is the single most surprising thing # in the package. # # FALSY PARAMS ARE DROPPED, SILENTLY. `request_impure' does `params or ()' # and `request_pure' then tests `if params', so an empty # tuple, an empty list, an empty dict and a bare 0 all produce # a request with NO `params' member. The specification permits # `"params": []', and there is no way to say it through this # API. Four cases; `req-params-list-of-zero' is the control # that shows the test is on the CONTAINER, not its contents. # # `notification' DOES NOT CONVERT ITS TUPLE AND `request' DOES. # `request_pure' carries `list(params) if isinstance(params, # tuple) else params'; `notification_pure' has no such line # and stores what it was handed. json.dumps hides the # difference on the wire -- both are arrays -- and nothing # hides it from a caller who inspects the returned dict. # `req-params-tuple-type' says `list' and # `note-params-tuple-type' says `tuple'. # # ============================================================================ # THE ID COUNTER IS PROCESS-WIDE AND SHARED BETWEEN TWO PUBLIC FUNCTIONS. # ============================================================================ # # `request = request_natural = partial(request_impure, id_generators.decimal())' # binds ONE `itertools.count' at import time, and `request_json = # compose(json.dumps, request_natural)' closes over THAT SAME PARTIAL. So # `request' and `request_json' draw from one counter: the thirteenth call, made # through `request_json', gets id 13. `natural-ids' pins the whole sequence as # one literal, which is the ticket's "the auto-generated id is unique across # calls" stated as the stronger property it actually has. # # `request_hex', `request_random' and `request_uuid' each bind their own, and # `request_json_hex' shares `request_hex''s by the same mechanism # (`hex-via-json' is id 3, not id 1). # # AN EXPLICIT `id=' DRAWS NOTHING. `request_pure' evaluates `next(id_generator)' # only in the branch it does not take, so five explicit-id calls sit between id # 12 and id 13 without moving the counter -- `req-explicit-no-draw'. And the # guard is `id is not NOID', an IDENTITY test, so 0, None and '' are all kept as # given rather than replaced. # # ============================================================================ # NO PATCH, NO BBAPPEND AND NO SITE ANSWER, AND THAT IS MEASURED. # ============================================================================ # # The recipe is meta-openembedded's exactly as written -- pure Python, no # ext_modules, no configure step and no compile step, so there is nothing on # this arm for an override to reach. The recipe-scope build ran 1622 of 1622 # tasks, all succeeded, zero ERROR: lines, and all six installed modules plus # `py.typed' are BYTE-IDENTICAL to the tree the SRCREV pins (`cmp' against the # checkout). `pkg-no-so' and `pkg-only-python' assert the shape that makes it # portable here at all: this image's python3 is ONE STATIC BINARY with an empty # lib-dynload and no dynamic loader (BRINTOS-1476), so a module carrying a # compiled extension could not be imported whatever it built to. # # ============================================================================ # THE ONE DEPENDENCY THAT IS RIGHT ONLY TRANSITIVELY. # ============================================================================ # # The recipe declares `python3-json python3-math python3-netclient' and the # built .deb reads `Depends: python3-core, python3-json, python3-math, # python3-netclient'. Each is there for a MODULE-SCOPE import: # # json requests.py:2, responses.py:3 -> python3-json # random id_generators.py:3 (choice) -> python3-math # uuid id_generators.py:6 (uuid4) -> python3-netclient # # `string' (id_generators.py:4, `ascii_lowercase' and `digits') is the fourth, # and it is NOT declared. OE splits it out as python3-stringold, which is # exactly the omission that made python3-jmespath undentifiable in this same # directory -- but here it is not an omission: python3-netclient RDEPENDS on # python3-stringold, so dpkg installs it for anyone who installs this package # and the closure is complete. NO BBAPPEND IS ADDED FOR IT, deliberately: what # is declared already provides it, and adding a name the declared set brings in # anyway is the redundancy the python3-jmespath note explicitly declines for # `random'. # # What that reasoning rests on is a fact about ANOTHER package, so the suite # checks the chain rather than assuming it: `stringold-installed', # `netclient-installed' and `netclient-depends-stringold' read the rootfs and # python3-netclient's own control stanza. If poky ever drops that rdepend, those # three go red HERE -- before `import jsonrpcclient' starts failing for a reason # nobody would look for in this package. # # ============================================================================ # WHAT IS NOT ASSERTED, AND WHY. # ============================================================================ # # `json-c-scanner' records whether CPython's C JSON scanner is reachable on this # arm and DOES NOT PIN THE ANSWER -- it is a property of how the interpreter was # linked, not of this package, and json falls back to its pure-Python scanner # either way. The case passes on either value and prints which, so the fact # lands in the transcript without inventing a prediction. `random' and `uuid' # have no such fallback (`random.py' line 64 is a bare `import _random'), which # is why `import-error' is emitted FIRST and ON ITS OWN: if the interpreter this # image ships cannot reach those, every case below is unscorable and this one # case says so instead of a hundred saying `got:'''. P=python3-jsonrpcclient # ------------------------------------------------------------------ canary -- bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/jsonrpcclient DI=$SITE/jsonrpcclient-4.0.3.dist-info PKGLIST=/var/lib/dpkg/info/python3-jsonrpcclient.list PROBE=$BST_DATA/python3-jsonrpcclient-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-jsonrpcclient-suite.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ------------------------------------------------------------- the payload -- check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKGDIR" check $P/is-a-package test -s "$PKGDIR/__init__.py" # The six modules, one case each, so a release that dropped or renamed one says # which rather than failing at an import three cases later. for m in __init__ requests responses id_generators sentinels utils; do check $P/module-$m test -s "$PKGDIR/$m.py" done # py.typed IS shipped (MANIFEST.in names it and setup.py's # include-package-data carries it into the wheel), so a type checker consuming # this package on target sees the inline annotations rather than falling back to # Any. check $P/py-typed test -f "$PKGDIR/py.typed" # MANIFEST.in ALSO NAMES `jsonrpcclient/response-schema.json' AND THERE IS NO # SUCH FILE in the tree the SRCREV pins, so it cannot ship. Asserted rather than # left implicit: a caller cannot validate against a schema this package does not # carry, and a release that restored it would change that. check $P/no-response-schema test ! -e "$PKGDIR/response-schema.json" check $P/pycache-init test -s "$PKGDIR/__pycache__/__init__.cpython-312.pyc" check $P/pycache-requests test -s "$PKGDIR/__pycache__/requests.cpython-312.pyc" check $P/pycache-responses test -s "$PKGDIR/__pycache__/responses.cpython-312.pyc" # ------------------------------------------------- the packaged file list --- cat "$PKGLIST" > "$W/pkgf" 2>/dev/null || : > "$W/pkgf" check $P/pkg-list-present test -s "$W/pkgf" # NO SHARED OBJECT. The assertion that makes the port portable: this image's # python3 has an empty lib-dynload and no dynamic loader (BRINTOS-1476), so a # release that grew a C accelerator would install it and die at `import # jsonrpcclient'. Caught here, at package scope, rather than there. check_eq $P/pkg-no-so "" \ "$(grep -E '\.so($|\.)' "$W/pkgf" 2>/dev/null)" # NOTHING IN /usr/bin. pyproject.toml declares no `scripts' and no # `project.scripts', so this package is importable-only -- there is no # `jsonrpcclient' command and a caller must not expect one. check_eq $P/pkg-no-bin "" \ "$(grep -E '^/usr/(s?bin)/' "$W/pkgf" 2>/dev/null)" # EVERYTHING SHIPPED IS PYTHON, dist-info metadata or py.typed. The general # form of the two negatives above, so a payload that grew a data file, a # manpage or a config says so. _bad="" while IFS= read -r _f; do [ -f "$_f" ] || continue case "$_f" in *.py|*.pyc) continue ;; */__pycache__/*) continue ;; */*.dist-info/*) continue ;; */py.typed) continue ;; esac _bad="$_bad $_f" done < "$W/pkgf" check_eq $P/pkg-only-python "" "$_bad" # ------------------------------------------------------------- dist-info ---- check $P/dist-info-dir test -d "$DI" check $P/dist-info-metadata test -f "$DI/METADATA" check_out $P/dist-info-name "Name: jsonrpcclient" cat "$DI/METADATA" check_out $P/dist-info-version "Version: 4.0.3" cat "$DI/METADATA" check_out $P/dist-info-summary "Summary: Send JSON-RPC requests" cat "$DI/METADATA" check $P/dist-info-record test -s "$DI/RECORD" check $P/dist-info-license test -s "$DI/LICENSE" # A WHEEL BUILT AS PURE PYTHON, which is the same statement `pkg-no-so' makes # from the other end: `Root-Is-Purelib: true' and a `py3-none-any' tag are what # a package with no compiled part produces, and a release that grew one would # change both. check_out $P/dist-info-generator "Generator: bdist_wheel" cat "$DI/WHEEL" check_out $P/dist-info-purelib "Root-Is-Purelib: true" cat "$DI/WHEEL" check_out $P/dist-info-tag-py3 "Tag: py3-none-any" cat "$DI/WHEEL" check_eq $P/dist-info-no-py2-tag "" \ "$(grep '^Tag: py2' "$DI/WHEEL" 2>/dev/null)" check_out $P/dist-info-top-level "jsonrpcclient" cat "$DI/top_level.txt" # ------------------------------------------------------------ dpkg status --- sed -n '/^Package: python3-jsonrpcclient$/,/^$/p' /var/lib/dpkg/status > "$W/st" 2>/dev/null check $P/pkg-known test -s "$W/st" check_out $P/pkg-installed "Status: install ok installed" cat "$W/st" check_out $P/pkg-version "Version: 4.0.3-r0" cat "$W/st" # THE DEPENDS LINE, VERBATIM. Three names the recipe declares plus the # python3-core setuptools3-base gives it. Asserted whole rather than by # substring, so a future bbappend that added or dropped one is visible here. grep '^Depends:' "$W/st" > "$W/dep" 2>/dev/null || : > "$W/dep" check $P/pkg-depends-line test -s "$W/dep" check_eq $P/pkg-depends-exact \ "Depends: python3-core, python3-json, python3-math, python3-netclient" \ "$(cat "$W/dep")" # ------------------------------- the `string' chain, checked and not assumed - # id_generators.py line 4 imports `string', which OE splits out as # python3-stringold and which THIS recipe does not declare. It arrives because # python3-netclient rdepends on it. That is a fact about python3-netclient, so # it is read off python3-netclient rather than believed. check $P/stringold-installed test -s "$SITE/../string.py" sed -n '/^Package: python3-netclient$/,/^$/p' /var/lib/dpkg/status > "$W/nc" 2>/dev/null check $P/netclient-installed test -s "$W/nc" check_out $P/netclient-depends-stringold "python3-stringold" \ grep '^Depends:' "$W/nc" # ------------------------------------------------------------- the import --- check $P/import python3 -c 'import jsonrpcclient' # AND IT MUST SAY NOTHING. A DeprecationWarning or a shim's chatter on stderr is # a defect for a library a caller pipes: the assertion is the empty string. check_eq $P/import-quiet "" \ "$(python3 -c 'import jsonrpcclient' 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 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-; } 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 check_eq $P/probe-complete "yes" "$(pv probe_done)" check_eq $P/probe-count "178" "$(pv emitted_count)" # EMITTED FIRST AND ON ITS OWN. `random.py' line 64 is a bare `import _random' # with no fallback, and `uuid' reaches `_uuid' in a try/except -- both are C # accelerators, and this image's lib-dynload is EMPTY. If the interpreter cannot # reach them, this one case says so and the hundred below do not each invent # their own symptom. check_eq $P/import-error "none" "$(pv import_error)" check_eq $P/module-answered "$PKGDIR/__init__.py" "$(pv module_file)" check_eq $P/is-package "yes" "$(pv is_package)" check_eq $P/submodules \ "__init__,id_generators,requests,responses,sentinels,utils" \ "$(pv submodules)" check_eq $P/py-typed-seen "yes" "$(pv py_typed)" check_eq $P/response-schema-absent "no" "$(pv response_schema)" # ----------------------------------------------------------- the API set ---- # `__all__' NAMES "request" TWICE -- once beside the notification helpers and # again beside Ok/Error. Fifteen entries, fourteen distinct. Harmless to a # `from jsonrpcclient import *', and a fact about the shipped file, so it is # pinned as the literal it is rather than de-duplicated away. check_eq $P/dunder-all-len "15" "$(pv dunder_all_len)" check_eq $P/dunder-all-has-dup "yes" "$(pv dunder_all_has_dup)" check_eq $P/dunder-all \ "notification,notification_json,request,request_hex,request_json,request_json_hex,request_json_random,request_json_uuid,request_random,request_uuid,request,Ok,Error,parse,parse_json" \ "$(pv dunder_all)" check_eq $P/dunder-all-unique \ "Error,Ok,notification,notification_json,parse,parse_json,request,request_hex,request_json,request_json_hex,request_json_random,request_json_uuid,request_random,request_uuid" \ "$(pv dunder_all_unique)" # Every name in __all__ is bound -- a `from jsonrpcclient import *' that raises # AttributeError is what this catches. check_eq $P/all-names-bound "none-missing" "$(pv all_names_bound)" # NO TRANSPORT AND NO BATCH HELPER, as absences over a fixed list of the names # a library that HAD them would expose. The ticket's premise, measured. check_eq $P/no-transport-names "none" "$(pv no_transport_names)" check_eq $P/no-batch-names "none" "$(pv no_batch_names)" # ---------------------------------------------- request generation, literal -- # The wire form is asserted as a LITERAL STRING, not only as a parsed structure: # `request_pure' builds the dict literal `jsonrpc, method[, params], id' and # json.dumps walks it as built, so the key order is fixed by the code and is # part of what this package promises. Every one of these carries # `jsonrpc: "2.0"' first, and `jsonrpc-everywhere' asserts that over all ten # request forms at once. check_eq $P/req-bare \ '{"jsonrpc": "2.0", "method": "ping", "id": 1}' "$(pv req_bare)" check_eq $P/req-pos-list \ '{"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 3], "id": 2}' \ "$(pv req_pos_list)" check_eq $P/req-pos-tuple \ '{"jsonrpc": "2.0", "method": "sum", "params": [1, 2, 3], "id": 3}' \ "$(pv req_pos_tuple)" check_eq $P/req-named \ '{"jsonrpc": "2.0", "method": "subtract", "params": {"minuend": 42, "subtrahend": 23}, "id": 6}' \ "$(pv req_named)" # A TUPLE BECOMES A LIST AND A LIST STAYS ONE -- `list(params) if # isinstance(params, tuple)'. The wire form above cannot show the difference # (both are arrays); the dict a caller inspects can, and # `note-params-tuple-type' below is the same measurement where the conversion # does NOT happen. check_eq $P/req-params-tuple-type "list" "$(pv req_params_tuple_type)" check_eq $P/req-params-list-type "list" "$(pv req_params_list_type)" # FALSY PARAMS ARE DROPPED. Four spellings a caller might reasonably use to # mean "no arguments, explicitly", each producing a request with NO `params' # member at all -- and there is no way through this API to send `"params": []', # which the specification permits. check_eq $P/req-params-empty-tuple \ '{"jsonrpc": "2.0", "method": "m", "id": 7}' "$(pv req_params_empty_tuple)" check_eq $P/req-params-empty-list \ '{"jsonrpc": "2.0", "method": "m", "id": 8}' "$(pv req_params_empty_list)" check_eq $P/req-params-empty-dict \ '{"jsonrpc": "2.0", "method": "m", "id": 9}' "$(pv req_params_empty_dict)" check_eq $P/req-params-zero \ '{"jsonrpc": "2.0", "method": "m", "id": 10}' "$(pv req_params_zero)" # The control: a list holding a falsy element is itself truthy and IS kept, so # the test is on the container and not on its contents. check_eq $P/req-params-list-of-zero \ '{"jsonrpc": "2.0", "method": "m", "params": [0], "id": 11}' \ "$(pv req_params_list_of_zero)" # AN EXPLICIT id IS KEPT WHATEVER IT IS. The guard is `id is not NOID', an # IDENTITY test, so 0 and None -- both of which a `if id' guard would have # thrown away -- survive as given. check_eq $P/req-explicit-int \ '{"jsonrpc": "2.0", "method": "m", "id": 99}' "$(pv req_explicit_int)" check_eq $P/req-explicit-zero \ '{"jsonrpc": "2.0", "method": "m", "id": 0}' "$(pv req_explicit_zero)" check_eq $P/req-explicit-none \ '{"jsonrpc": "2.0", "method": "m", "id": null}' "$(pv req_explicit_none)" check_eq $P/req-explicit-str \ '{"jsonrpc": "2.0", "method": "m", "id": "abc"}' "$(pv req_explicit_str)" check_eq $P/req-explicit-with-params \ '{"jsonrpc": "2.0", "method": "m", "params": [1], "id": "x"}' \ "$(pv req_explicit_with_params)" # AND IT DRAWS NOTHING FROM THE COUNTER. Five explicit-id calls sit between id # 12 and the id 11 before them; `next()' is evaluated only in the branch # request_pure does not take. check_eq $P/req-explicit-no-draw \ '{"jsonrpc": "2.0", "method": "after-explicit", "id": 12}' \ "$(pv req_explicit_no_draw)" # `request_json' IS `request' COMPOSED WITH json.dumps, over the SAME partial # and therefore the SAME counter -- so its id is 13, continuing the sequence, # not 1 starting its own. check_eq $P/req-json-str \ '{"jsonrpc": "2.0", "method": "rj", "id": 13}' "$(pv req_json_str)" check_eq $P/req-json-type "str" "$(pv req_json_type)" check_eq $P/req-json-separators "yes" "$(pv req_json_separators)" # THE WHOLE SEQUENCE, IN CALL ORDER: 1..13, no gaps, no repeats. This is the # ticket's "the auto-generated id is asserted unique across calls" stated as the # stronger property the code actually has. check_eq $P/natural-ids "1,2,3,4,5,6,7,8,9,10,11,12,13" "$(pv natural_ids)" check_eq $P/natural-ids-unique "yes" "$(pv natural_ids_unique)" check_eq $P/natural-ids-are-int "yes" "$(pv natural_ids_are_int)" # ------------------------------------------------------ the other id arms ---- # Each of these binds its OWN generator, so its counter is independent of the # natural one -- and `request_json_hex' shares `request_hex''s by the same # composition mechanism, which is why `hex-via-json' is 3 and not 1. check_eq $P/hex-1 "1" "$(pv hex_1)" check_eq $P/hex-2 "2" "$(pv hex_2)" check_eq $P/hex-via-json "3" "$(pv hex_via_json)" # Ten draws in, the counter is past 9 and into the letters -- the only place a # hexadecimal id looks different from a decimal one, and the point of the arm. check_eq $P/hex-tenth "a" "$(pv hex_tenth)" check_eq $P/random-len "8" "$(pv random_len)" check_eq $P/random-is-str "yes" "$(pv random_is_str)" check_eq $P/random-charset "yes" "$(pv random_charset)" # Twenty draws, twenty distinct. Not a uniqueness GUARANTEE -- upstream's own # docstring puts the collision chance at about one in a million -- but a # generator that had stopped advancing shows up here at once. check_eq $P/random-distinct "yes" "$(pv random_distinct)" check_eq $P/random-via-json-len "8" "$(pv random_via_json_len)" check_eq $P/uuid-len "36" "$(pv uuid_len)" check_eq $P/uuid-dashes "4" "$(pv uuid_dashes)" # uuid4, so the version nibble is 4 and the variant nibble is one of 8,9,a,b. # This is also the one case that would go red if `uuid' fell back to a degraded # implementation on an arm with no `_uuid'. check_eq $P/uuid-version "4" "$(pv uuid_version)" check_eq $P/uuid-variant "yes" "$(pv uuid_variant)" check_eq $P/uuid-distinct "yes" "$(pv uuid_distinct)" check_eq $P/uuid-via-json-len "36" "$(pv uuid_via_json_len)" # `jsonrpc: "2.0"' IN EVERY ONE OF THE TEN REQUEST FORMS -- the four id arms, # their four json spellings, and both notification spellings. Asserted over all # of them at once, so a release that dropped it from one arm is caught wherever # it dropped it. check_eq $P/jsonrpc-everywhere "yes" "$(pv jsonrpc_everywhere)" check_eq $P/jsonrpc-form-count "10" "$(pv jsonrpc_form_count)" # ------------------------------------------------------------ notifications -- # A NOTIFICATION IS A REQUEST WITHOUT AN id, and that is the whole of the # difference: `notification_pure' never reaches the generator, so a notification # draws no id and cannot be correlated with a response. The specification says # a server must not answer one. check_eq $P/note-bare '{"jsonrpc": "2.0", "method": "ping"}' "$(pv note_bare)" check_eq $P/note-bare-keys "jsonrpc,method" "$(pv note_bare_keys)" check_eq $P/note-has-id "no" "$(pv note_has_id)" check_eq $P/note-named \ '{"jsonrpc": "2.0", "method": "subtract", "params": {"a": 1}}' \ "$(pv note_named)" # THE ASYMMETRY: `request' converts a tuple to a list and `notification' does # not. Same package, same author, one line apart in the source. check_eq $P/note-params-tuple-type "tuple" "$(pv note_params_tuple_type)" check_eq $P/note-pos-tuple-json \ '{"jsonrpc": "2.0", "method": "sum", "params": [1, 2]}' \ "$(pv note_pos_tuple_json)" check_eq $P/note-pos-list-json \ '{"jsonrpc": "2.0", "method": "sum", "params": [1, 2]}' \ "$(pv note_pos_list_json)" check_eq $P/note-params-empty \ '{"jsonrpc": "2.0", "method": "m"}' "$(pv note_params_empty)" check_eq $P/note-json-str \ '{"jsonrpc": "2.0", "method": "ping"}' "$(pv note_json_str)" check_eq $P/note-json-type "str" "$(pv note_json_type)" # A BATCH REQUEST IS THE CALLER'S OWN JSON ARRAY. There is no helper # (`no-batch-names' above), so this is the documented spelling: build the # members, hand the list to json.dumps. A notification inside a batch still # carries no id, which is what makes a mixed batch the interesting case. check_eq $P/batch-request-json \ '[{"jsonrpc": "2.0", "method": "one", "id": "b1"}, {"jsonrpc": "2.0", "method": "two"}, {"jsonrpc": "2.0", "method": "three", "params": [1], "id": "b3"}]' \ "$(pv batch_request_json)" check_eq $P/batch-len "3" "$(pv batch_len)" check_eq $P/batch-mixed-ids "b1,,b3" "$(pv batch_mixed_ids)" # --------------------------------------------------------- parsing: Ok ------ # `Ok' is a NamedTuple, so it is BOTH an object with fields and a 2-tuple, and # callers use both -- each access is asserted rather than one standing in for # the other. check_eq $P/ok-type "Ok" "$(pv ok_type)" check_eq $P/ok-fields "result,id" "$(pv ok_fields)" check_eq $P/ok-result "19" "$(pv ok_result)" check_eq $P/ok-id "1" "$(pv ok_id)" check_eq $P/ok-index-0 "19" "$(pv ok_index_0)" check_eq $P/ok-index-1 "1" "$(pv ok_index_1)" check_eq $P/ok-len "2" "$(pv ok_len)" check_eq $P/ok-is-tuple "yes" "$(pv ok_is_tuple)" # The repr is upstream's own `__repr__', not the NamedTuple default, so it is # part of what the package promises and is pinned verbatim. check_eq $P/ok-repr "Ok(result=19, id=1)" "$(pv ok_repr)" check_eq $P/ok-asdict '{"result": 19, "id": 1}' "$(pv ok_asdict)" # EVERY JSON TYPE AS A RESULT. `ok-result-null' is the one that matters: # `"result" in response' is a KEY test, so a null result is an Ok carrying None # and NOT an error -- which a caller writing `if response.result' gets wrong, # along with `ok-result-false', `ok-result-zero', `ok-result-emptystr', # `ok-result-emptyarray' and `ok-result-emptyobject'. check_eq $P/ok-result-null "Ok:null" "$(pv ok_result_null)" check_eq $P/ok-result-true "Ok:true" "$(pv ok_result_true)" check_eq $P/ok-result-false "Ok:false" "$(pv ok_result_false)" check_eq $P/ok-result-int "Ok:7" "$(pv ok_result_int)" check_eq $P/ok-result-negint "Ok:-7" "$(pv ok_result_negint)" check_eq $P/ok-result-float "Ok:1.5" "$(pv ok_result_float)" check_eq $P/ok-result-str 'Ok:"hello"' "$(pv ok_result_str)" check_eq $P/ok-result-emptystr 'Ok:""' "$(pv ok_result_emptystr)" check_eq $P/ok-result-zero "Ok:0" "$(pv ok_result_zero)" check_eq $P/ok-result-array 'Ok:[1, "a", null]' "$(pv ok_result_array)" check_eq $P/ok-result-object 'Ok:{"k": [1, 2]}' "$(pv ok_result_object)" check_eq $P/ok-result-emptyarray "Ok:[]" "$(pv ok_result_emptyarray)" check_eq $P/ok-result-emptyobject "Ok:{}" "$(pv ok_result_emptyobject)" # ------------------------------------------------------ parsing: Error ------ # The error arm reads `code' and `message' with `[]' -- REQUIRED, and the # `bad-err-no-*' cases below are what happens when they are absent -- and # `data' with `.get()'. check_eq $P/err-type "Error" "$(pv err_type)" check_eq $P/err-fields "code,message,data,id" "$(pv err_fields)" check_eq $P/err-code "-32601" "$(pv err_code)" check_eq $P/err-message "Method not found" "$(pv err_message)" check_eq $P/err-id "1" "$(pv err_id)" check_eq $P/err-len "4" "$(pv err_len)" check_eq $P/err-is-tuple "yes" "$(pv err_is_tuple)" check_eq $P/err-repr \ "Error(code=-32601, message='Method not found', data=None, id=1)" \ "$(pv err_repr)" check_eq $P/err-index "-32601|Method not found|null|1" "$(pv err_index)" # `data' IS OPTIONAL, AND ITS ABSENCE IS INDISTINGUISHABLE FROM AN EXPLICIT # null. `.get("data")' returns None either way, so a protocol that gives the # two different meanings cannot be implemented on top of this package. Three # cases, and the third is the one that says the information is gone. check_eq $P/err-data-absent "null" "$(pv err_data_absent)" check_eq $P/err-data-present '{"detail": "x"}' "$(pv err_data_present)" check_eq $P/err-data-explicit-null "null" "$(pv err_data_explicit_null)" check_eq $P/err-data-null-indistinguishable "yes" \ "$(pv err_data_null_indistinguishable)" # THE SPECIFICATION'S RESERVED CODES, each parsed and every field read back. # The point is not that the library validates them -- `err-code-unreserved' and # `err-code-not-int' show it does not -- but that every one survives the trip # with its message and its data intact. check_eq $P/err-code-parse-error \ "Error|-32700|Parse error|-32700" "$(pv err_code_32700)" check_eq $P/err-code-invalid-request \ "Error|-32600|Invalid Request|-32600" "$(pv err_code_32600)" check_eq $P/err-code-method-not-found \ "Error|-32601|Method not found|-32601" "$(pv err_code_32601)" check_eq $P/err-code-invalid-params \ "Error|-32602|Invalid params|-32602" "$(pv err_code_32602)" check_eq $P/err-code-internal-error \ "Error|-32603|Internal error|-32603" "$(pv err_code_32603)" check_eq $P/err-code-server-low \ "Error|-32000|Server error|-32000" "$(pv err_code_32000)" check_eq $P/err-code-server-high \ "Error|-32099|Server error|-32099" "$(pv err_code_32099)" # NOTHING IS VALIDATED. A code outside every reserved range comes back as # given, and so does one that is not an integer at all -- the NamedTuple's # `code: int' annotation is a hint and is not enforced at runtime. check_eq $P/err-code-unreserved "Error|1234" "$(pv err_code_unreserved)" check_eq $P/err-code-not-int "Error|oops|str" "$(pv err_code_not_int)" # ---------------------------------------------------- parsing: a batch ------ # THE SINGLE MOST SURPRISING THING IN THE PACKAGE. `parse' of a list is # `map(to_response, deserialized)': a LAZY ITERATOR, not a list. `len()' raises, # and the sequence is consumed once -- a caller who logs the batch and then # handles it handles an empty one, with no error anywhere. check_eq $P/batch-parse-type "map" "$(pv batch_parse_type)" check_eq $P/batch-is-list "no" "$(pv batch_is_list)" check_eq $P/batch-len-raises "!TypeError" "$(pv batch_len_raises)" check_eq $P/batch-first-pass "Ok,Error,Ok" "$(pv batch_first_pass)" check_eq $P/batch-first-ids "1,2,3" "$(pv batch_first_ids)" check_eq $P/batch-second-pass "0" "$(pv batch_second_pass_len)" check_eq $P/batch-empty "0" "$(pv batch_empty)" check_eq $P/batch-json-type "map" "$(pv batch_json_type)" check_eq $P/batch-json-results "1,2" "$(pv batch_json_results)" # --------------------------------------------------------- parse_json ------- check_eq $P/pj-ok "Ok(result='x', id=1)" "$(pv pj_ok)" check_eq $P/pj-err \ "Error(code=-32601, message='nope', data=None, id=1)" "$(pv pj_err)" # Whitespace and key order on the wire are irrelevant: json.loads has already # made them so before to_response sees anything. check_eq $P/pj-reordered "Ok(result=[1, 2], id=5)" "$(pv pj_reordered)" # ============================================================================ # MALFORMED INPUT, AND THE THREE PREDICTIONS IT REFUTES. # ============================================================================ # 1. TEXT THAT IS NOT JSON. The one the ticket gets right -- and it fails in # json.loads rather than in this package. JSONDecodeError IS a ValueError, so # a caller catching ValueError catches it; that is asserted separately # because it is the thing a caller has to know. check_eq $P/bad-not-json "!JSONDecodeError" "$(pv bad_not_json)" check_eq $P/bad-not-json-valueerror "yes" "$(pv bad_not_json_is_valueerror)" check_eq $P/bad-truncated "!JSONDecodeError" "$(pv bad_truncated)" check_eq $P/bad-empty-string "!JSONDecodeError" "$(pv bad_empty_string)" # 2. A RESPONSE WITH NO `jsonrpc' MEMBER PARSES. The ticket expects a # documented failure; `to_response' never looks at the key. And a response # declaring the WRONG version parses too -- there is no version check # anywhere in the package. check_eq $P/bad-no-jsonrpc "Ok(result=1, id=1)" "$(pv bad_no_jsonrpc)" check_eq $P/bad-wrong-version "Ok(result=1, id=1)" "$(pv bad_wrong_version)" # 3. BOTH `result' AND `error' IS AN Ok, AND THE ERROR IS DISCARDED WITHOUT A # WORD. `"result" in response' is tested first and wins. The mirror image -- # a null result beside a real error -- goes the same way, because the test is # on the KEY and not on the value, which is the case a caller is most likely # to meet in the wild. check_eq $P/bad-both "Ok(result=1, id=1)" "$(pv bad_both)" check_eq $P/bad-both-null-result "Ok(result=None, id=1)" \ "$(pv bad_both_null_result)" # 4. AN `id' THAT MATCHES NO REQUEST HAS NOTHING TO BE MATCHED AGAINST. The # package holds no state between a request and a response -- there is no # registry of outstanding calls anywhere in it -- so correlation is entirely # the caller's job and any id whatsoever comes back unremarked. check_eq $P/bad-unknown-id "Ok(result=1, id='never-sent')" \ "$(pv bad_unknown_id)" check_eq $P/bad-null-id "Ok(result=1, id=None)" "$(pv bad_null_id)" # WHAT DOES FAIL: the three `[]' lookups, as KeyError, plus a TypeError where # `error' is not subscriptable. Every one is an unhandled exception out of a # nine-line function, so a caller parsing untrusted input must wrap it. check_eq $P/bad-no-id "!KeyError" "$(pv bad_no_id)" check_eq $P/bad-neither "!KeyError" "$(pv bad_neither)" check_eq $P/bad-err-no-code "!KeyError" "$(pv bad_err_no_code)" check_eq $P/bad-err-no-message "!KeyError" "$(pv bad_err_no_message)" check_eq $P/bad-err-not-dict "!TypeError" "$(pv bad_err_not_dict)" check_eq $P/bad-err-null "!TypeError" "$(pv bad_err_null)" check_eq $P/bad-empty-object "!KeyError" "$(pv bad_empty_object)" check_eq $P/bad-scalar "!TypeError" "$(pv bad_scalar)" check_eq $P/bad-none "!TypeError" "$(pv bad_none)" # THE ONE CASE THE PACKAGE ITSELF DIAGNOSES, and the message is its own rather # than CPython's -- so it is pinned verbatim. The guard is at the TOP of # `parse', so it fires on any string, JSON or not, and it fires through # `parse_json' too when the JSON document IS a string. check_eq $P/bad-str-to-parse "!TypeError:Use parse_json on strings" \ "$(pv bad_str_to_parse)" check_eq $P/bad-str-not-json "!TypeError:Use parse_json on strings" \ "$(pv bad_str_not_json)" check_eq $P/bad-json-is-string "!TypeError:Use parse_json on strings" \ "$(pv bad_json_is_string)" # A JSON document that is a bare number gets past that guard and fails in # `to_response' instead, with CPython's message rather than the package's. check_eq $P/bad-json-is-number "!TypeError" "$(pv bad_json_is_number)" # ------------------------------------------------------------ the sentinel -- # `NOID' is deliberately not a None: `sentinels.py' says so in its docstring, # because None is a valid id. Its repr is the reason the class exists at all. check_eq $P/noid-repr "" "$(pv noid_repr)" check_eq $P/noid-name "NoId" "$(pv noid_name)" # It is NOT exported from the top level, so a caller who needs to say "generate # one" explicitly has to reach into a private-looking submodule. check_eq $P/noid-at-toplevel "no" "$(pv noid_at_toplevel)" # Identity, not equality, is what `request_pure' tests -- so a second Sentinel # with the same name is NOT the sentinel, and passing one would land it in the # request as a literal id. check_eq $P/noid-identity "yes" "$(pv noid_identity)" check_eq $P/sentinel-distinct "yes" "$(pv sentinel_distinct)" check_eq $P/noid-explicit-draws "yes" "$(pv noid_explicit_draws)" # --------------------------------------------------------- utils.compose ---- # Three lines of `functools.reduce' that the whole `*_json' surface is built # out of: `request_json', `notification_json' and `parse_json' are each one # `compose' call and nothing else. check_eq $P/compose-two "3" "$(pv compose_two)" check_eq $P/compose-three "4" "$(pv compose_three)" check_eq $P/compose-one "5" "$(pv compose_one)" # Right to left, like the mathematics and unlike a pipeline. check_eq $P/compose-order "HI!" "$(pv compose_order)" # The INNERMOST function takes the arguments, keywords included -- which is why # `request_json(method, params, id=...)' works at all. check_eq $P/compose-kwargs "{'a': 1}" "$(pv compose_kwargs)" # ---------------------------------------------------------- id_generators --- # The four generators on their own, away from the request helpers that bind # them -- so a red here says the generator is wrong rather than the wiring. check_eq $P/gen-decimal "1,2,3" "$(pv gen_decimal)" check_eq $P/gen-decimal-start "5,6,7" "$(pv gen_decimal_start)" # The rollover from digits into letters, which is the only visible difference # between the hexadecimal arm and the decimal one. check_eq $P/gen-hex-rollover "8,9,a,b" "$(pv gen_hex_rollover)" check_eq $P/gen-hex-wide "ff" "$(pv gen_hex_wide)" check_eq $P/gen-random-len "4" "$(pv gen_random_len)" check_eq $P/gen-random-chars "yes" "$(pv gen_random_chars)" check_eq $P/gen-uuid-shape "36/4" "$(pv gen_uuid_shape)" # `decimal' is `itertools.count' -- an iterator, not a list. The same laziness # `parse' has, in the place a caller expects it. check_eq $P/gen-decimal-type "count" "$(pv gen_decimal_type)" # ------------------------------------------- the stdlib this actually reaches - # One case per module, so a future poky split that moved one says WHICH. # `random' and `uuid' are the two that are not free on this arm: both reach C # accelerators and this image's lib-dynload is empty. for m in json random string uuid itertools functools typing sys; do check_eq $P/imports-$m "yes" "$(pv imports_$m)" done # NOT PINNED, DELIBERATELY. Whether CPython's C JSON scanner is reachable is a # property of how this interpreter was linked, not of this package, and json # falls back to its pure-Python scanner either way. The case passes on either # answer and prints which, so the fact reaches the transcript without inventing # a prediction to measure it against. _jcs="$(pv json_c_scanner)" case "$_jcs" in yes|no) bst_ok $P/json-c-scanner ;; *) bst_notok $P/json-c-scanner "expected yes or no, got '$_jcs'" ;; esac # ------------------------------------------------------------------ hygiene -- # THE ABSENCE OF A TRANSPORT, MEASURED. Importing the package must not drag in a # socket, an ssl context, an http client or an event loop -- scored against a # fixed list, so the case says something about the package rather than about # whatever else the interpreter happened to load. check_eq $P/transport-modules "none" "$(pv transport_modules)" check_eq $P/socket-imported "no" "$(pv socket_imported)" check_eq $P/own-modules \ "jsonrpcclient,jsonrpcclient.id_generators,jsonrpcclient.requests,jsonrpcclient.responses,jsonrpcclient.sentinels,jsonrpcclient.utils" \ "$(pv own_modules)" rm -rf "$W"