. "$BST_LIB" # BST-TIMEOUT: 1200 # TWO python3 starts, and they are the whole cost. Every # # other case below reads the filesystem or dpkg with shell # # builtins, or greps the output of the second start. # # # # The two are: a bare `import' (one case, so that an import # # failure is legible without reading a transcript) and the # # probe, which takes ONE start for a hundred-odd # # measurements. The work inside each is dictionary # # operations on objects of two or three items -- # # microseconds beside an interpreter start on this arm, # # which is tens of seconds before the first line of the # # program runs. # # # # NOTHING HERE SLEEPS, forks a child, opens a socket or # # starts a thread. The package is 11 KB of pure Python # # whose only imports are `sys', `copy' and `collections', # # and the probe builds every fixture it uses, so there is # # no wall-clock in the suite at all beyond the two starts. # # # # 1200 is 600s per start, ten times the ~60s a python3 # # start has measured at here and the same per-start budget # # 99-python3-visitor gives its two. # # # # NOT MEASURED ON TARGET YET: this port's first on-target # # run is the BRINTOS-2047 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-pydicti 1.2.1 (meta-openembedded/meta-python # recipes-devtools/python/python3-pydicti_1.2.1.bb, `inherit pypi setuptools3', # PyPI sdist) on the wasm32-hwjs glibc image. # # ============================================================================ # WHAT THE PACKAGE DOES, AND WHAT THAT MAKES THE SUITE ASSERT. # ============================================================================ # # `dicti' is a dict whose LOOKUPS are case insensitive while its STORAGE is # not. It is a `builtins.dict' SUBCLASS -- deliberately, and the module says so # at length -- so `isinstance(d, dict)' holds and `json.dump' works with no # `default='. It keeps two mappings: the inherited dict holds # original_case => value, and a private `__case' holds normalized => original. # Every case-insensitive operation is that second lookup followed by the first. # # `build_dicti(base)' is what makes any mapping type case insensitive by # subclassing it, and it MEMOIZES: `build_dicti(dict) is dicti' and # `build_dicti(OrderedDict) is odicti'. `Dicti(obj)' picks the class from the # argument's own type. # # So the interesting claim is almost never "does the lookup ignore case" -- one # case settles that. It is WHICH OF THE TWO MAPPINGS AN OPERATION TOUCHES, and # every behavioural case below is written to distinguish the two where a naive # implementation would have conflated them. # # ---------------------------------------------------------------------------- # OVERWRITING THROUGH A DIFFERENTLY-CASED KEY KEEPS THE ORIGINAL CASE. # ---------------------------------------------------------------------------- # # This is the thing about the package a reader is most likely to get wrong, and # BRINTOS-2061's own description got it wrong -- it asked this suite to assert # that the overwrite "update[s] the stored case as documented". It does not. # `__setitem__' reads # # lower = normalize(key) # if lower in self.__case: # key = self.__case[lower] # <-- the INCOMING case is discarded # dict_.__setitem__(self, key, value) # # so `d['KEY'] = v' on a `d' that already holds `Key' replaces the VALUE and # leaves the stored key spelt `Key'. `overwrite-value' and `overwrite-key-case' # are those two halves. # # The method's own docstring says the opposite -- "Set the value for `key' and # assume new case" -- so the code and its documentation disagree, and the # description inherited the docstring's reading. `reinsert-key-case' is what # makes the measured rule a rule rather than an accident of one call: DELETE # then re-insert DOES adopt the new case, because `__delitem__' drops the # `__case' entry that the overwrite path reuses. `update-key-case' shows the # same rule reached through `update', which is `MutableMapping''s loop over # `self[k] = v' and so cannot differ. # # ---------------------------------------------------------------------------- # `==' AND `!=' ARE BOTH TRUE FOR THE SAME PAIR OF OPERANDS. # ---------------------------------------------------------------------------- # # The class defines `__eq__' and leaves `__ne__' alone. On a plain `object' # subclass that would be enough -- `object.__ne__' delegates to `__eq__' -- but # `dict' supplies its own, so `dicti' INHERITS A CASE-SENSITIVE `!='. The # result is that # # dicti([('Hello', 1)]) == {'HELLO': 1} -> True # dicti([('Hello', 1)]) != {'HELLO': 1} -> True # # `eq-and-ne-both-true' asserts the contradiction directly, and `has-own-ne' # says where it comes from. This is pinned, not attributed: it is upstream # behaviour reproduced identically on an x86-64 control, so it is not about # this arm, and it is what a caller writing `if d != cfg' needs to know. # # ---------------------------------------------------------------------------- # THE NORMALIZER IS `casefold()', NOT `lower()', AND IT IS A LADDER. # ---------------------------------------------------------------------------- # # `normalize_case' tries `casefold()', then `lower()', then returns the object # unchanged. Three rungs, and each is visible in exactly one place: # # * `casefold' rather than `lower' shows only outside ASCII, so # `casefold-sharp-s' (U+00DF folds to `ss') and `casefold-kelvin' (U+212A # folds to `k') are the two cases a release that swapped in `lower' would # turn red -- and nothing else would notice. # * `bytes' has `lower' but no `casefold', so BYTES KEYS ARE CASE INSENSITIVE # TOO (`bytes-key-ci'). Nothing documents this. # * an object with neither is returned as itself, which is what lets a # non-string key be stored and compared unchanged (`nonstr-*'). The key # must merely be HASHABLE, and an unhashable one is a `TypeError' -- through # `get' as well, which catches only `KeyError' (`unhashable-get'). # # ---------------------------------------------------------------------------- # THE BASE CLASS'S SEMANTICS SURVIVE INTO THE DERIVED COMPARISON. # ---------------------------------------------------------------------------- # # `__eq__' compares `lower_dict()', which is an instance of the BASE type. So # two `dicti' differing only in insertion order are equal and two `odicti' are # NOT (`eq-dicti-order-insensitive' / `eq-odicti-diff-order') -- OrderedDict's # own `__eq__' against another OrderedDict is order sensitive, and that reaches # through. That pair is the whole argument for `build_dicti' subclassing rather # than wrapping, so it is asserted rather than described. # # ---------------------------------------------------------------------------- # EVERY EXPECTED VALUE BELOW WAS MEASURED, NOT DERIVED. # ---------------------------------------------------------------------------- # # The probe was run on an x86-64 control at the same version -- pydicti 1.2.1 # from the sdist whose sha256 the recipe pins, under CPython 3.12.3 -- and then # re-run against THIS BUILD'S OWN .deb payload, which produced a transcript # byte-identical apart from the module's path. So a red here is a statement # about wasm32-hwjs and not about a value somebody guessed. P=python3-pydicti # ============================================================================ # The suite proper. # ============================================================================ # # `suite-canary' is emitted FIRST and unconditionally. A suite that dies before # its first result line is indistinguishable from one that was never scheduled, # so this one line is what separates "ran and found nothing" from "did not run". bst_ok $P/suite-canary require python3 $P/python3-present SITE=/usr/lib/python3.12/site-packages PKGDIR=$SITE/pydicti MOD=$PKGDIR/__init__.py INNER=$PKGDIR/pydicti.py DI=$SITE/pydicti-1.2.1.dist-info PKGLIST=/var/lib/dpkg/info/python3-pydicti.list PROBE=$BST_DATA/python3-pydicti-probe.py tmp=${TMPDIR:-/tmp} W=$tmp/bst-pydicti.$$ mkdir -p "$W" || { bst_notok $P/workdir "cannot create $W"; exit 0; } # ---------------------------------------------------------------- payload --- # WHERE THE PACKAGING SAYS THE MODULE LANDS. These run before any import, so a # red here says the .deb is wrong rather than that the module misbehaves. check $P/site-packages-dir test -d "$SITE" check $P/package-dir test -d "$PKGDIR" check $P/module-present test -s "$MOD" # THE PAYLOAD IS A PACKAGE OF TWO MODULES, not one. `__init__.py' is a 168-byte # re-export shim and every line of the implementation is in `pydicti.py' beside # it -- so the package and the inner module share a name, and `pydicti.pydicti' # is a real attribute of the imported package (`has-inner-module' below). Both # files are asserted, because a release that flattened them would satisfy # `import' and every behavioural case while relocating the whole payload. check $P/inner-module-present test -s "$INNER" check $P/not-a-single-module test ! -e "$SITE/pydicti.py" # THE SIZES ARE PINNED RATHER THAN FLOORED: a truncated or partially written # payload passes `test -s' and fails only here. Both figures are the sdist's own # byte counts, and both shipped files were verified byte-identical to it. check_eq $P/init-size 168 \ "$(wc -c < "$MOD" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) check_eq $P/inner-size 11340 \ "$(wc -c < "$INNER" 2>/dev/null | tr -d ' ')" # BST-REDIRECT-OK: forked -- inside $( ) # THE PACKAGE IS EXACTLY THOSE TWO FILES. `find_packages()' found one package; # a release that grew a third module would be noticed here rather than going # unexercised by a suite that only ever imports the top level. check_eq $P/package-py-count 2 \ "$(find "$PKGDIR" -maxdepth 1 -name '*.py' 2>/dev/null | wc -l | tr -d ' ')" # NO COMPILED EXTENSION SHIPS, and this is the case that would notice a release # that grew one. On this image a `.so' in site-packages is not a speed-up, it is # an ImportError: python3 here is one static binary with an empty lib-dynload # (BRINTOS-1476). Asserted over the payload rather than over the recipe. check_eq $P/no-so "" "$(find "$PKGDIR" -name '*.so' 2>/dev/null)" # NO EXECUTABLE. setup.cfg declares no `entry_points' and no `scripts', so # nothing lands in /usr/bin. Asserted so a future release that started shipping # one is noticed rather than silently unexercised. check $P/no-bin-script test ! -e /usr/bin/pydicti # The byte-compiled copies are part of the payload and are why `import' on this # target is a read rather than a parse of 11 KB of source. BOTH modules are # compiled and both land in the PACKAGE's own `__pycache__', so both are pinned # by path -- a payload carrying only the shim's would still import, slowly, and # nothing else in the suite would see it. check $P/pycache-init test -s "$PKGDIR/__pycache__/__init__.cpython-312.pyc" check $P/pycache-inner test -s "$PKGDIR/__pycache__/pydicti.cpython-312.pyc" # ------------------------------------------------------------- dist-info --- # The installed metadata, which is what `importlib.metadata' answers from. check $P/dist-info-dir test -d "$DI" check $P/dist-info-record test -s "$DI/RECORD" check $P/dist-info-metadata test -s "$DI/METADATA" check_eq $P/dist-info-top-level "pydicti" \ "$(cat "$DI/top_level.txt" 2>/dev/null | tr -d ' \r')" # THE WHEEL IS PURE AND UNIVERSAL -- `Root-Is-Purelib: true' with a `py2' tag # beside the `py3' one. That is what says the payload is arch-independent, which # is the reason this port needed no bbappend, no patch and no site answer, and # it is worth an assertion rather than a sentence. check $P/wheel-purelib grep -q '^Root-Is-Purelib: true$' "$DI/WHEEL" # The LICENSE ships in the dist-info. The recipe's LIC_FILES_CHKSUM checksums # COPYING at build time; this is the runtime half -- the file is actually on the # image rather than merely having been checked. check $P/license-present test -s "$DI/LICENSE" # --------------------------------------------------------------- dpkg --- # The package is REGISTERED, not merely unpacked. A payload that arrived some # other way would pass every path case above and fail here. check $P/dpkg-list test -s "$PKGLIST" check $P/dpkg-lists-module grep -q "^$MOD\$" "$PKGLIST" check $P/dpkg-lists-inner grep -q "^$INNER\$" "$PKGLIST" # ------------------------------------------------------------------ import --- # ONE case for the bare import, so that an import failure is legible from the # console without reading the probe's transcript. check $P/import python3 -c 'import pydicti' # ------------------------------------------------------------------- probe --- if [ ! -s "$PROBE" ]; then bst_notok $P/probe-present "probe missing at $PROBE" exit 0 fi bst_ok $P/probe-present # 900s, inside the 1200s suite budget so a hung probe is reported as a probe # overrun with the cases named rather than as a bare suite timeout. # # The probe EXITS 0 even when the import fails, so this case passing means "the # probe ran", not "the module works" -- `import-ok' below is what carries that. bst_probe $P/probe-run "$W/probe" 900 python3 "$PROBE" OUT="$W/probe" # pv : the value the probe emitted for , or empty. `head -n 1' because # a key is emitted once and a duplicate would otherwise concatenate; `cut -f2-' # because a value may legitimately contain `='. pv() { grep "^$1=" "$OUT" 2>/dev/null | head -n 1 | cut -d= -f2-; } # The probe reached its end. Every case below reads a value it printed, so a # probe that stopped early would otherwise turn into a run of empty-string # mismatches that all name the wrong thing. check_eq $P/probe-complete "yes" "$(pv probe_done)" _imp=$(pv import_ok) check_eq $P/import-ok "yes" "$_imp" if [ "$_imp" != "yes" ]; then # The module did not import. Skip -- not red -- every behavioural case, each # with the reason attached: they are all one fact, and a hundred reds would # bury it rather than report it. `import' and `import-ok' above are the two # reds that carry the verdict, and the design doc attributes them. _why="pydicti did not import: $(pv import_error)" for _c in probe-count module-file module-name is-package version all-list \ public-names dicti-bases odicti-bases dicti-is-dict-subclass \ odicti-is-ordereddict-subclass dicti-name odicti-name \ dicti-qualname odicti-qualname mangled-attr-dicti \ mangled-attr-odicti get-upper get-lower get-asis get-mixed \ len-initial iter-keys keys-call items-keys repr-str str-str \ odicti-repr overwrite-value overwrite-key-case overwrite-len \ reinsert-key-case reinsert-value update-key-case update-value \ update-kwargs-key contains-upper contains-lower contains-missing \ get-ci get-default setdefault-existing setdefault-existing-case \ setdefault-new setdefault-new-case pop-ci pop-removed pop-default \ pop-missing-raises del-ci-removed len-after-del \ del-missing-raises getitem-missing-raises clear-len \ clear-contains clear-then-set-case popitem-pair popitem-len \ eq-plain-dict-diff-case eq-reflected-plain-dict \ eq-plain-dict-diff-value eq-same-type-diff-case \ eq-dicti-vs-odicti eq-dicti-order-insensitive \ eq-odicti-diff-order eq-non-mapping ne-plain-dict-diff-case \ eq-and-ne-both-true ne-plain-dict-same-case has-own-ne \ has-normalize-case has-inner-module inner-has-normalize-case \ casefold-sharp-s casefold-kelvin normalize-int normalize-bytes \ normalize-str nonstr-int nonstr-tuple nonstr-none \ nonstr-contains nonstr-key-types bytes-key-ci bytes-key-case \ unhashable-getitem unhashable-get build-dict-is-dicti \ build-ordereddict-is-odicti build-twice-identical built-order \ built-ci-lookup built-is-ordereddict build-non-mapping \ build-non-type build-name-ignored-when-cached build-name-short \ build-name-qualname build-name-repr dicti-of-dict-type \ dicti-of-ordered-type dicti-of-ordered-is-odicti copy-type \ copy-equal copy-independent copy-shallow deepcopy-type \ deepcopy-independent or-type or-keys or-value or-case-kept \ ior-value dict-of-dicti-keys lower-dict lower-items \ lower-dict-type odicti-lower-dict-type json-dumps \ json-roundtrip-eq pickle-roundtrip-type pickle-roundtrip-case \ pickle-roundtrip-ci reduce-callable reduce-callable-is-dicti \ reduce-args-empty dicti-module odicti-module \ odicti-pickle-roundtrip-type odicti-pickle-roundtrip-ci \ odicti-has-own-reduce dicti-has-own-reduce; do bst_skip $P/$_c "$_why" done exit 0 fi # The probe emitted every key it intended to. A probe that returned 0 having # skipped a section would otherwise leave those cases reading empty strings. check_eq $P/probe-count "134" "$(pv emitted_count)" # --------------------------------------------------------- module surface --- # WHERE THE IMPORT ACTUALLY CAME FROM. Pinned to the site-packages path so an # import satisfied by a stray copy on `sys.path' -- a leftover in the cwd, a # second install -- is a red here rather than a green suite testing the wrong # file. check_eq $P/module-file "$MOD" "$(pv module_file)" check_eq $P/module-name "pydicti" "$(pv module_name)" check_eq $P/is-package "yes" "$(pv is_package)" # THE VERSION IS RE-EXPORTED, unlike most of these ports where it lives only in # the dist-info. It agrees with the recipe's `PV', which is what makes this a # check that the image holds the version the recipe named. check_eq $P/version "1.2.1" "$(pv version)" # THE PUBLIC SURFACE, pinned as an exact list rather than a membership test, so # a release that added a fifth entry point is noticed here rather than shipping # unexercised. `public_names' carries one entry `__all__' does not: the INNER # MODULE, which `from .pydicti import ...' binds as an attribute of the package # as a side effect of importing it. check_eq $P/all-list "Dicti,build_dicti,dicti,odicti" "$(pv all_list)" check_eq $P/public-names "Dicti,build_dicti,dicti,odicti,pydicti" "$(pv public_names)" # THE TWO CLASSES DERIVE FROM THE MAPPINGS THEY WRAP -- the module's central # design decision, and the one every case in the equality section rests on. check_eq $P/dicti-bases "dict" "$(pv dicti_bases)" check_eq $P/odicti-bases "OrderedDict" "$(pv odicti_bases)" check_eq $P/dicti-is-dict-subclass "yes" "$(pv dicti_is_dict_subclass)" check_eq $P/odicti-is-ordereddict-subclass "yes" "$(pv odicti_is_ordereddict_subclass)" check_eq $P/dicti-name "dicti" "$(pv dicti_name)" check_eq $P/odicti-name "odicti" "$(pv odicti_name)" check_eq $P/dicti-qualname "dicti" "$(pv dicti_qualname)" check_eq $P/odicti-qualname "odicti" "$(pv odicti_qualname)" # EVERY GENERATED CLASS SHARES ONE MANGLED ATTRIBUTE NAME. The class statement # inside `_make_dicti' is literally `class Dicti', so `self.__case' mangles to # `_Dicti__case' for `dicti', for `odicti' and for anything `build_dicti' makes # -- the mangling follows the SOURCE name and not the `__name__' the factory # then assigns. Asserted on both types because a reader inspecting an `odicti' # would look for `_odicti__case' and find nothing. check_eq $P/mangled-attr-dicti "yes" "$(pv mangled_attr_dicti)" check_eq $P/mangled-attr-odicti "yes" "$(pv mangled_attr_odicti)" # ------------------------------------------------ case-insensitive lookup --- # The one fact the package exists for, asserted in all four cases the stored # key could be reached by. The fixture stores `Key', so `get-asis' is the only # one of the four that is an ordinary dict lookup. check_eq $P/get-upper "val" "$(pv get_upper)" check_eq $P/get-lower "val" "$(pv get_lower)" check_eq $P/get-asis "val" "$(pv get_asis)" check_eq $P/get-mixed "val" "$(pv get_mixed)" check_eq $P/len-initial "2" "$(pv len_initial)" # ...AND THE STORED CASE IS THE INSERTED ONE. Iteration, `keys()', `items()' # and both string forms are inherited from the base, so they report what was # stored and never the normalized form the lookup went through. All four are # asserted rather than one, because they reach the storage by four different # inherited paths and a `dicti' that overrode any of them would be wrong in a # way the other three would not show. check_eq $P/iter-keys "Key,Two" "$(pv iter_keys)" check_eq $P/keys-call "Key,Two" "$(pv keys_call)" check_eq $P/items-keys "Key,Two" "$(pv items_keys)" # `repr' NAMES THE CLASS AND IS INVERTIBLE by design -- `%s(%s)' over # `__class__.__name__' and `__str__' -- which is the whole reason `build_dicti' # bothers to assign `__name__' at all. check_eq $P/repr-str "dicti({'Key': 'val', 'Two': 2})" "$(pv repr_str)" check_eq $P/str-str "{'Key': 'val', 'Two': 2}" "$(pv str_str)" check_eq $P/odicti-repr "odicti({'Ab': 1, 'cD': 2})" "$(pv odicti_repr)" # ------------------------------------------------------- overwrite: THE CASE --- # See the header. The value is replaced and the STORED CASE IS NOT -- the # opposite of `__setitem__''s own docstring and of what this ticket's # description asked for. These three are the finding. check_eq $P/overwrite-value "v2" "$(pv overwrite_value)" check_eq $P/overwrite-key-case "Key" "$(pv overwrite_key_case)" check_eq $P/overwrite-len "1" "$(pv overwrite_len)" # ...AND DELETE-THEN-REINSERT DOES ADOPT THE NEW CASE, because `__delitem__' # drops the `__case' entry the overwrite path reuses. This pair is what makes # the rule above a rule rather than an accident of one call. check_eq $P/reinsert-key-case "kEy" "$(pv reinsert_key_case)" check_eq $P/reinsert-value "v3" "$(pv reinsert_value)" # `update' TAKES THE SAME PATH. It is `MutableMapping''s loop over # `self[k] = v', so a differently-cased key arriving through `update' cannot # behave differently from a direct assignment -- asserted because "cannot" here # rests on which `update' the class body bound, and that is a line of code. check_eq $P/update-key-case "Key" "$(pv update_key_case)" check_eq $P/update-value "9" "$(pv update_value)" check_eq $P/update-kwargs-key "Key,Third" "$(pv update_kwargs_key)" # --------------------------------------------- membership and accessors --- check_eq $P/contains-upper "yes" "$(pv contains_upper)" check_eq $P/contains-lower "yes" "$(pv contains_lower)" check_eq $P/contains-missing "no" "$(pv contains_missing)" check_eq $P/get-ci "val" "$(pv get_ci)" check_eq $P/get-default "dflt" "$(pv get_default)" # `setdefault' ON A PRESENT KEY RETURNS THE STORED VALUE AND CHANGES NOTHING -- # not the value, and not the case, because it never reaches `__setitem__'. On # an ABSENT key it does reach it, and the case it inserts is the one it was # given. The pair is the same storage-versus-lookup distinction as the # overwrite cases, arrived at through a `MutableMapping' method. check_eq $P/setdefault-existing "val" "$(pv setdefault_existing)" check_eq $P/setdefault-existing-case "Key" "$(pv setdefault_existing_case)" check_eq $P/setdefault-new "n" "$(pv setdefault_new)" check_eq $P/setdefault-new-case "Key,NEW" "$(pv setdefault_new_case)" check_eq $P/pop-ci "val" "$(pv pop_ci)" check_eq $P/pop-removed "yes" "$(pv pop_removed)" check_eq $P/pop-default "dflt" "$(pv pop_default)" check_eq $P/pop-missing-raises "KeyError" "$(pv pop_missing_raises)" check_eq $P/del-ci-removed "yes" "$(pv del_ci_removed)" check_eq $P/len-after-del "0" "$(pv len_after_del)" check_eq $P/del-missing-raises "KeyError" "$(pv del_missing_raises)" check_eq $P/getitem-missing-raises "KeyError" "$(pv getitem_missing_raises)" # `clear' MUST EMPTY THE CASE MAP TOO. One that forgot would leave `in' # answering yes for a key the dict no longer holds, and `clear-then-set-case' # would show the stale case coming back on the next insert. That is the whole # reason the method is overridden, so the three are asserted together. check_eq $P/clear-len "0" "$(pv clear_len)" check_eq $P/clear-contains "no" "$(pv clear_contains)" check_eq $P/clear-then-set-case "Fresh" "$(pv clear_then_set_case)" check_eq $P/popitem-pair "Only:7" "$(pv popitem_pair)" check_eq $P/popitem-len "0" "$(pv popitem_len)" # ------------------------------------------------------------- equality --- # AGAINST A PLAIN dict, COMPARISON IS CASE INSENSITIVE. `__eq__' wraps a # mapping that has no `lower_dict' in the module-level `Dicti' factory and # compares lowered forms, so a plain dict differing only in case is EQUAL. check_eq $P/eq-plain-dict-diff-case "yes" "$(pv eq_plain_dict_diff_case)" # ...and the same holds written the other way round, which is NOT automatic: # `dicti' is a dict SUBCLASS, so Python offers it the reflected operand first # and `dict.__eq__' -- which would have said no -- never runs. A release that # stopped subclassing `dict' would flip this one and leave the line above green. check_eq $P/eq-reflected-plain-dict "yes" "$(pv eq_reflected_plain_dict)" check_eq $P/eq-plain-dict-diff-value "no" "$(pv eq_plain_dict_diff_value)" check_eq $P/eq-same-type-diff-case "yes" "$(pv eq_same_type_diff_case)" check_eq $P/eq-dicti-vs-odicti "yes" "$(pv eq_dicti_vs_odicti)" # THE BASE CLASS'S SEMANTICS REACH THROUGH. Both comparisons take the # `type(other) is type(self)' fast path and compare `lower_dict()' -- a plain # dict for `dicti', an OrderedDict for `odicti', and OrderedDict's own `__eq__' # against another OrderedDict is ORDER SENSITIVE. So the same two items in the # same two orders are equal as `dicti' and unequal as `odicti'. This pair is # the argument for subclassing rather than wrapping, and it is asserted rather # than described. check_eq $P/eq-dicti-order-insensitive "yes" "$(pv eq_dicti_order_insensitive)" check_eq $P/eq-odicti-diff-order "no" "$(pv eq_odicti_diff_order)" check_eq $P/eq-non-mapping "no" "$(pv eq_non_mapping)" # `==' AND `!=' ARE BOTH TRUE FOR THE SAME OPERANDS. See the header: # `__ne__' is not overridden and `dict' supplies a case-SENSITIVE one, so the # negation of `__eq__' is not what `!=' computes. `has-own-ne' names the cause # and `ne-plain-dict-same-case' is the control -- with the cases agreeing, `!=' # gives the answer a reader expects, which is why the contradiction goes unseen. check_eq $P/ne-plain-dict-diff-case "yes" "$(pv ne_plain_dict_diff_case)" check_eq $P/eq-and-ne-both-true "yes" "$(pv eq_and_ne_both_true)" check_eq $P/ne-plain-dict-same-case "no" "$(pv ne_plain_dict_same_case)" check_eq $P/has-own-ne "no" "$(pv has_own_ne)" # ------------------------------------------------------------ normalize --- # THE NORMALIZER IS NOT RE-EXPORTED. `pydicti/__init__.py' imports exactly the # four public names, so the hook a caller passes to `build_dicti' to get a # different equivalence is reachable only as `pydicti.pydicti.normalize_case'. # All three asserted, because the absence is what sends a reader to the inner # module and the inner module is only reachable by the accident above. check_eq $P/has-normalize-case "no" "$(pv has_normalize_case)" check_eq $P/has-inner-module "yes" "$(pv has_inner_module)" check_eq $P/inner-has-normalize-case "yes" "$(pv inner_has_normalize_case)" # `casefold()', NOT `lower()' -- and these two are the only places the # difference shows. U+00DF folds to `ss', U+212A (KELVIN SIGN) folds to `k'. # A release that swapped in `lower' would turn exactly these red. check_eq $P/casefold-sharp-s "yes" "$(pv casefold_sharp_s)" check_eq $P/casefold-kelvin "yes" "$(pv casefold_kelvin)" # THE THREE RUNGS OF THE LADDER, read off the normalizer directly rather than # through a dictionary: a str folds, a bytes falls to `lower', an int is # returned as itself. check_eq $P/normalize-str "mixed" "$(pv normalize_str)" check_eq $P/normalize-bytes "b'abc'" "$(pv normalize_bytes)" check_eq $P/normalize-int "42" "$(pv normalize_int)" # NON-STRING KEYS ARE ACCEPTED AND COMPARED UNCHANGED -- the ticket's own # requirement, and the third rung's consequence. The key must merely be # hashable; `nonstr-key-types' pins that the stored keys are the objects # themselves and not a normalized stand-in. check_eq $P/nonstr-int "int" "$(pv nonstr_int)" check_eq $P/nonstr-tuple "tuple" "$(pv nonstr_tuple)" check_eq $P/nonstr-none "none" "$(pv nonstr_none)" check_eq $P/nonstr-contains "yes" "$(pv nonstr_contains)" check_eq $P/nonstr-key-types "int,tuple,NoneType" "$(pv nonstr_key_types)" # BYTES KEYS ARE CASE INSENSITIVE TOO, by the second rung. Nothing documents # this and it does not read off the docstring, which talks only about strings. check_eq $P/bytes-key-ci "yes" "$(pv bytes_key_ci)" check_eq $P/bytes-key-case "b'ABC'" "$(pv bytes_key_case)" # AN UNHASHABLE KEY IS A `TypeError', NOT A `KeyError' -- including through # `get', which catches only `KeyError'. So `get' is NOT total over arbitrary # objects, which is the opposite of what its name promises a caller. check_eq $P/unhashable-getitem "TypeError" "$(pv unhashable_getitem)" check_eq $P/unhashable-get "TypeError" "$(pv unhashable_get)" # ---------------------------------------------------------- build_dicti --- # THE CLASS TABLE IS THE POINT. `build_dicti' returns an EXISTING type for a # base it has already seen, so a caller gets the module-level name back rather # than a fresh look-alike -- which would fail the `type(other) is type(self)' # fast path in `__eq__' above and silently take the slower, differently # behaved branch. check_eq $P/build-dict-is-dicti "yes" "$(pv build_dict_is_dicti)" check_eq $P/build-ordereddict-is-odicti "yes" "$(pv build_ordereddict_is_odicti)" check_eq $P/build-twice-identical "yes" "$(pv build_twice_identical)" # `build_dicti(OrderedDict)' IS BOTH CASE INSENSITIVE AND ORDER PRESERVING -- # this ticket's own requirement. The insertion order is deliberately NOT sorted, # so a plain-dict base would have to be lucky to pass. check_eq $P/built-order "Bee,Ay,Cee" "$(pv built_order)" check_eq $P/built-ci-lookup "2" "$(pv built_ci_lookup)" check_eq $P/built-is-ordereddict "yes" "$(pv built_is_ordereddict)" check_eq $P/build-non-mapping "TypeError" "$(pv build_non_mapping)" check_eq $P/build-non-type "TypeError" "$(pv build_non_type)" # THE CACHE WINS OVER `name', AND SILENTLY. A base already in the table returns # the stored class and the `name' argument is DISCARDED -- documented, and the # kind of thing a caller discovers from a `repr' naming somebody else's class # rather than from an error. check_eq $P/build-name-ignored-when-cached "dicti" "$(pv build_name_ignored_when_cached)" # On a base the table has NOT seen, `name' is honoured -- and it is SPLIT: the # last dotted component becomes `__name__' while the whole string becomes # `__qualname__'. So `repr' shows the short name and a traceback shows the path. check_eq $P/build-name-short "Inner" "$(pv build_name_short)" check_eq $P/build-name-qualname "Outer.Inner" "$(pv build_name_qualname)" check_eq $P/build-name-repr "Inner({'Kk': 1})" "$(pv build_name_repr)" # `Dicti(obj)' PICKS THE CLASS FROM THE ARGUMENT'S OWN TYPE, which is why its # name matches the one `build_dicti' used and why `repr' round-trips. check_eq $P/dicti-of-dict-type "dicti" "$(pv Dicti_of_dict_type)" check_eq $P/dicti-of-ordered-type "odicti" "$(pv Dicti_of_ordered_type)" check_eq $P/dicti-of-ordered-is-odicti "yes" "$(pv Dicti_of_ordered_is_odicti)" # ----------------------------------------------- copying and protocols --- check_eq $P/copy-type "dicti" "$(pv copy_type)" check_eq $P/copy-equal "yes" "$(pv copy_equal)" check_eq $P/copy-independent "yes" "$(pv copy_independent)" # A SHALLOW COPY SHARES THE VALUES. `copy' is `__copy__' is # `self.__class__(self)', so a mutable value is the SAME object in both -- # asserted so a reader does not take `copy' for `deepcopy', and paired with # `deepcopy-independent' which is the contrast. check_eq $P/copy-shallow "yes" "$(pv copy_shallow)" check_eq $P/deepcopy-type "dicti" "$(pv deepcopy_type)" check_eq $P/deepcopy-independent "yes" "$(pv deepcopy_independent)" # PEP 584 `|' AND `|=' ARE IMPLEMENTED ON TOP OF `update', so the merge is case # insensitive and the LEFT operand's case survives a right-hand key that # differs only in case -- the overwrite rule again, reached through an operator. check_eq $P/or-type "dicti" "$(pv or_type)" check_eq $P/or-keys "Key,Other" "$(pv or_keys)" check_eq $P/or-value "2" "$(pv or_value)" check_eq $P/or-case-kept "yes" "$(pv or_case_kept)" check_eq $P/ior-value "5" "$(pv ior_value)" # `dict(d)' BYPASSES `__iter__'/`__getitem__' and reads the inherited storage # directly -- the pitfall the module's own docstring calls out. It yields the # ORIGINAL-CASE keys, which is exactly why the storage is kept that way round. check_eq $P/dict-of-dicti-keys "Key,Two" "$(pv dict_of_dicti_keys)" check_eq $P/lower-dict "key,two" "$(pv lower_dict)" check_eq $P/lower-items "key,two" "$(pv lower_items)" check_eq $P/lower-dict-type "dict" "$(pv lower_dict_type)" check_eq $P/odicti-lower-dict-type "OrderedDict" "$(pv odicti_lower_dict_type)" # JSON ROUND-TRIPS WITHOUT A `default=', because `dicti' IS a dict as far as # the encoder's fast path is concerned -- and the fast path reads the storage, # so the emitted key carries the ORIGINAL case. The docstring advertises this; # it is asserted because it is the integration a caller is most likely to rely # on, and it is the one place the case-preserving storage becomes visible # outside the process. check_eq $P/json-dumps '{"Hello": "foo"}' "$(pv json_dumps)" check_eq $P/json-roundtrip-eq "yes" "$(pv json_roundtrip_eq)" # PICKLE ROUND-TRIPS, AND THE CALLABLE `__reduce__' RETURNS IS THE CLASS -- # not the module-level one-argument `Dicti' factory that shares its name. # `__reduce__' is nested inside the FUNCTION `_make_dicti', whose local `Dicti' # is the class being built, so the name closes over the class and never reaches # module globals. The two readings differ only in scope rules and give opposite # answers -- module globals would make every unpickle a `TypeError' -- so this # is measured rather than reasoned, and pinned so a refactor that hoisted # `_make_dicti' out of a function is caught by a red rather than by a caller. check_eq $P/pickle-roundtrip-type "dicti" "$(pv pickle_roundtrip_type)" check_eq $P/pickle-roundtrip-case "Key" "$(pv pickle_roundtrip_case)" check_eq $P/pickle-roundtrip-ci "yes" "$(pv pickle_roundtrip_ci)" check_eq $P/reduce-callable "dicti" "$(pv reduce_callable)" check_eq $P/reduce-callable-is-dicti "yes" "$(pv reduce_callable_is_dicti)" check_eq $P/reduce-args-empty "yes" "$(pv reduce_args_empty)" # ...and that is only reachable because the class is FINDABLE BY NAME. pickle # stores the callable as `.', so `__module__' -- which # `build_dicti' sets from its CALLER's frame, and which is therefore the INNER # module rather than the package -- has to name where the two are bound. check_eq $P/dicti-module "pydicti.pydicti" "$(pv dicti_module)" check_eq $P/odicti-module "pydicti.pydicti" "$(pv odicti_module)" # `odicti' DOES NOT GET THE `__reduce__' OVERRIDE -- `if dict_ is dict' guards # it -- so it pickles through OrderedDict's own handler instead. It round-trips # too; the point of the four is that the two types get there by different # routes, and only one of them needed the workaround. check_eq $P/odicti-pickle-roundtrip-type "odicti" "$(pv odicti_pickle_roundtrip_type)" check_eq $P/odicti-pickle-roundtrip-ci "yes" "$(pv odicti_pickle_roundtrip_ci)" check_eq $P/odicti-has-own-reduce "no" "$(pv odicti_has_own_reduce)" check_eq $P/dicti-has-own-reduce "yes" "$(pv dicti_has_own_reduce)" rm -rf "$W"