#!/usr/bin/env python3 """One interpreter start for the whole of 99-python3-jmespath. Emits `key=value' lines, one per line, in file order; the suite reads them with grep. A python3 start on this arm is tens of seconds and every case in the suite is a parse and a walk over a document of a few hundred bytes -- microseconds apiece -- so the start is the entire cost and taking it once is the difference between a half-minute suite and a half-hour one. THREE RULES THE VALUES OBEY. The runner parses its input line by line, so no value may contain a newline. Every result is therefore rendered through `j()' -- `json.dumps' with the separators pinned and `sort_keys' set -- which is compact, newline-free, and canonical for a dict whatever order the interpreter happened to build it in. No value may depend on the encoding stdout happens to be in. The fixture is ASCII and every expected value below is ASCII; the one deliberate non-ASCII case emits a code-point sequence rather than the characters. And an exception is reported as `' rather than allowed to end the run. THE FAILURE PATH IS HALF THE CONTRACT of this package -- a malformed expression and a wrongly-typed function argument are specified to raise, and which exception they raise is what a caller branches on -- so a raise is a MEASUREMENT here and the suite asserts the type by name. The message is deliberately not included: upstream's wording is not part of the JMESPath specification and a release that reworded it would red every such case while the contract was intact. """ import json import sys emitted = [] def emit(key, value): emitted.append(key) print('%s=%s' % (key, value)) def j(value): """A JSON value as one canonical, newline-free line.""" return json.dumps(value, separators=(',', ':'), sort_keys=True, ensure_ascii=True) try: import jmespath from jmespath import exceptions as jexc except Exception as exc: # pragma: no cover print('import_error=%s: %s' % (type(exc).__name__, exc)) sys.exit(1) # ---------------------------------------------------------------- identity --- # WHICH COPY ANSWERED. Every case below is a statement about the package named # here; without this one they would be statements about whatever was first on # sys.path. emit('package_file', getattr(jmespath, '__file__', '')) # ...and it IS a package -- a DIRECTORY of eight modules, not one file. The API # is re-exported through __init__.py, so a build that collapsed the eight into # one, or lost one submodule's SOURCE while keeping its bytecode, would still # import and still pass every behavioural case below. emit('is_package', 'yes' if hasattr(jmespath, '__path__') else 'no') emit('version_attr', getattr(jmespath, '__version__', '')) try: from importlib.metadata import version as _dist_version emit('dist_version', _dist_version('jmespath')) except Exception as exc: emit('dist_version', '<%s>' % type(exc).__name__) # THE STDLIB MODULES THE PACKAGE REACHES FOR, imported by name and one at a # time. This is not decoration: OE splits the stdlib into sub-packages and this # recipe's RDEPENDS names only three of them, so a module the package imports # and the recipe does not declare is present here only by the accident of some # OTHER package having pulled it in. `string' is the one that matters -- # lexer.py's first line is `import string' and it lives in python3-stringold, # which upstream's RDEPENDS does not name. See the bbappend. for _m in ('string', 'random', 'math', 'json', 'numbers', 'operator', 'inspect', 'itertools', 'warnings'): try: __import__(_m) emit('stdlib_' + _m, 'yes') except Exception as exc: emit('stdlib_' + _m, '<%s>' % type(exc).__name__) # ----------------------------------------------------------- the fixture ----- # ONE document for every expression case below. It is deliberately small and # deliberately irregular: `machines' carries a member missing `state' so a # projection has something to drop, `nested' is three levels deep so `flatten' # has two levels to collapse, and `falsy' holds each of the four values the # specification calls false so the truthiness group can name them one at a time. DOC = { "locations": [ {"name": "Seattle", "state": "WA"}, {"name": "New York", "state": "NY"}, {"name": "Bellevue", "state": "WA"}, {"name": "Olympia", "state": "WA"}, ], "machines": [ {"name": "a", "cpu": 4, "state": "running"}, {"name": "b", "cpu": 16, "state": "stopped"}, {"name": "c", "cpu": 8, "state": "running"}, {"name": "d", "cpu": 2}, ], "nested": {"a": [[1, 2], [3, 4]], "b": [[5], [6, 7]]}, "people": {"alice": {"age": 30}, "bob": {"age": 25}}, "numbers": [4, -1, 10, 2], "words": ["delta", "alpha", "charlie", "bravo"], "falsy": {"list": [], "obj": {}, "str": "", "null": None, "zero": 0, "false": False}, "deep": {"one": {"two": {"three": "found"}}}, "text": "hello world", } def q(key, expression, data=DOC): """Emit jmespath.search(expression, data), or the exception TYPE by name.""" try: emit(key, j(jmespath.search(expression, data))) except Exception as exc: emit(key, '<%s>' % type(exc).__name__) # ------------------------------------------------- identifiers and paths ----- # The two constructs everything else is built on: a field, and a field of a # field. `deep_path' is three dots, which is what says the sub-expression # chains rather than being a special case of one level. q('id_field', 'text') q('id_missing', 'nosuchkey') q('id_subexpr', 'people.alice.age') q('id_deep', 'deep.one.two.three') # A path THROUGH a missing key answers null rather than raising. This is the # single most load-bearing property of the language for a caller -- it is why a # JMESPath expression can be applied to a document whose shape is not known -- # and it holds at every level, not only the last. q('id_missing_mid', 'deep.nope.two.three') # ...and a path through a NON-OBJECT is null too, not a type error. visit_field # catches AttributeError; a string has no `.get'. q('id_through_scalar', 'text.anything') # The identity expression `@' is the whole document. q('id_identity_type', 'type(@)') # ------------------------------------------------------------- indexing ----- # Index expressions, including the negative form the specification requires. q('idx_first', 'numbers[0]') q('idx_last', 'numbers[-1]') # OUT OF RANGE IS NULL, NOT AN ERROR -- the other half of the shape-agnostic # property above, and the case that separates JMESPath's indexing from Python's. q('idx_out_of_range', 'numbers[99]') q('idx_negative_out_of_range', 'numbers[-99]') # An index applied to a NON-ARRAY is null, not a character or a raise: JMESPath # has no string indexing even though the host language does. q('idx_on_string', 'text[0]') # --------------------------------------------------------------- slices ----- # Slices are `[start:stop:step]' with every part optional, and they follow # Python's half-open convention. The stepped and negative forms are here # because they are the ones an implementation is most likely to get wrong. q('slice_range', 'numbers[0:2]') q('slice_open_start', 'numbers[:2]') q('slice_open_stop', 'numbers[2:]') q('slice_step', 'numbers[::2]') q('slice_reverse', 'numbers[::-1]') q('slice_negative', 'numbers[-2:]') # A slice whose bounds select nothing is the EMPTY ARRAY, not null. That is the # distinction the truthiness group below rests on: `[]' is a value, and it is a # value the specification calls false. q('slice_empty', 'numbers[3:1]') # A slice of a non-array is null -- consistent with `idx_on_string' and, again, # not Python's answer. q('slice_on_string', 'text[0:5]') # ---------------------------------------------------------- projections ----- # A LIST WILDCARD projects the right-hand side over every element and DROPS the # nulls. `machines[*].state' is the case that shows the drop: the fourth # machine has no `state', so four elements project to three. A reader who # expects a null-padded list of four gets the wrong length everywhere downstream. q('proj_list', 'locations[*].name') q('proj_drops_null', 'machines[*].state') q('proj_drops_null_len', 'length(machines[*].state)') # A HASH WILDCARD projects over a dict's VALUES, in insertion order. q('proj_hash', 'people.*.age') # A projection of a non-array is null, which is how a wildcard applied to the # wrong shape stays quiet rather than raising. q('proj_on_scalar', 'text[*]') # FLATTEN collapses ONE level per `[]', and `flat_twice' is what says so: the # fixture is three deep, so one `[]' leaves arrays and two leaves scalars. q('flat_once', 'nested.a[]') q('flat_twice', 'nested.*[]') q('flat_of_flat', 'nested.a[][]') # ------------------------------------------------------ filter projections --- # `[?expr]' keeps the elements the expression is TRUE for, by the # specification's truthiness rather than the host language's. q('filter_eq', 'locations[?state == `"WA"`].name') q('filter_ne', 'locations[?state != `"WA"`].name') q('filter_gt', 'machines[?cpu > `4`].name') q('filter_lte', 'machines[?cpu <= `4`].name') # A BOOLEAN expression inside the filter -- `&&' and `||' are usable there, and # they are the same operators the truthiness group asserts below. q('filter_and', 'machines[?cpu > `2` && state == `"running"`].name') q('filter_or', 'machines[?cpu == `2` || cpu == `16`].name') q('filter_not', 'machines[?!(state == `"running"`)].name') # A FILTER ON A MISSING KEY drops the element rather than raising: the fourth # machine has no `state', so it is absent from every `state'-testing filter # above and present in the `!' one only because `!null' is true. q('filter_missing_key', 'machines[?state == `"running"`].name') # A filter matching nothing is the empty array. q('filter_none', 'machines[?cpu > `1000`].name') # ORDERING COMPARATORS ARE NUMBER-ONLY IN THE SPECIFICATION AND NOT IN THIS # IMPLEMENTATION. `_is_comparable' admits strings on purpose -- visitor.py says # so in a comment: "The spec doesn't officially support string types yet, but # enough people are relying on this behavior". So a `<' between two strings # answers here and would answer null on a conforming implementation. Recorded # as what it is: a deliberate upstream extension, not a platform effect. q('cmp_string_lt', '`"a"` < `"b"`') q('cmp_string_gt', '`"b"` < `"a"`') # ...AND MIXING THE TWO RAISES A BARE TypeError, which is the one place this # package leaks an exception outside its own hierarchy. `_is_comparable' is # true for the string AND for the number, so the guard that returns null for # incomparable operands does not fire, and `operator.lt(str, int)' is reached. # A caller writing `except jmespath.exceptions.JMESPathError' does NOT catch it. # Upstream 1.0.1's behaviour, not this platform's -- see the header of the suite. q('cmp_mixed_types', '`"a"` < `1`') q('cmp_mixed_in_filter', 'machines[?name > `1`].name') # A NON-ORDERING comparison of mixed types is fine -- equality never orders, so # it answers false rather than raising. The pair is what localises the defect # to the ordering path. q('cmp_mixed_equality', '`"a"` == `1`') # ------------------------------------------------------- multiselect --------- # A multiselect LIST builds an array from several expressions; a multiselect # HASH builds an object with the keys named in the expression. Both are how a # JMESPath expression RESHAPES a document rather than merely selecting from it. q('multi_list', 'locations[0].[name, state]') q('multi_hash', 'locations[0].{City: name, State: state}') q('multi_hash_missing', 'machines[3].{n: name, s: state}') q('multi_list_projected', 'locations[*].[name, state]') q('multi_hash_projected', 'machines[*].{n: name}') # A multiselect list KEEPS its nulls, where a projection drops them. Same # document, same missing key, opposite answer -- which is the pair that says the # dropping belongs to the projection and not to the missing key. q('multi_list_keeps_null', 'machines[3].[name, state]') # ------------------------------------------------------------ pipe ---------- # `|' stops a projection and starts a new one over the result, which is the only # way to apply a function to a projection's OUTPUT rather than to each element. q('pipe_basic', 'locations[*].name | [0]') q('pipe_length', 'machines[*].name | length(@)') # WITHOUT the pipe the same expression means something else: `[0]' inside the # projection would index each NAME rather than the list of names. This is the # pair that shows the pipe is load-bearing rather than punctuation. q('pipe_absent_differs', 'locations[*].name[0]') q('pipe_chained', 'machines[*] | [?cpu > `4`] | [0].name') # ----------------------------------------------- the truthiness rules -------- # JMESPATH'S FALSE VALUES ARE NOT PYTHON'S, and the difference is the reason # each of these is asserted individually rather than as a group. The # specification calls exactly five things false -- the empty array, the empty # object, the empty string, `null' and `false' -- and `0' is NOT among them. # visitor.py's `_is_false' is written with `==' comparisons for exactly this # reason, and an implementation that used Python's `bool()' would differ on `0' # alone. q('truth_or_empty_list', 'falsy.list || `"fallback"`') q('truth_or_empty_obj', 'falsy.obj || `"fallback"`') q('truth_or_empty_str', 'falsy.str || `"fallback"`') q('truth_or_null', 'falsy.null || `"fallback"`') q('truth_or_false', 'falsy.false || `"fallback"`') # THE ONE THAT DIFFERS FROM PYTHON: zero is TRUE, so `||' returns it rather than # falling through. A port that got this wrong would pass every case above. q('truth_or_zero', 'falsy.zero || `"fallback"`') # `||' RETURNS A VALUE, not a boolean -- the left operand when it is true. q('truth_or_returns_value', 'text || `"fallback"`') # `&&' returns the LEFT operand when it is false and the right one otherwise, # which is the mirror of `||' and the same value-not-boolean rule. q('truth_and_false_left', 'falsy.list && `"right"`') q('truth_and_true_left', 'text && `"right"`') q('truth_and_zero_left', 'falsy.zero && `"right"`') # `!' NEGATES BY THE SAME RULES, with `!`0`' special-cased in # visit_not_expression so that zero stays true through the negation too. # # THE PARENTHESES ARE LOAD-BEARING AND THIS IS THE PORT'S SHARPEST FINDING. # `not' binds at 45 in the parser's table and `dot' at 40, so `!a.b' parses as # `(!a).b' and NOT as `!(a.b)'. `!falsy.zero' therefore negates the OBJECT # `falsy' -- true, so the negation is false -- and then asks a boolean for a # field, which visit_field answers null for. The result is `null': not the # `false' the reader wanted, and not an error either, so an expression written # without the parentheses is silently wrong at every call. Both forms are # emitted, side by side, because the pair is the only way to state it. q('truth_not_dotted_unparenthesised', '!falsy.zero') q('truth_not_zero', '!(falsy.zero)') q('truth_not_empty_list', '!(falsy.list)') q('truth_not_empty_obj', '!(falsy.obj)') q('truth_not_empty_str', '!(falsy.str)') q('truth_not_null', '!(falsy.null)') q('truth_not_false', '!(falsy.false)') # A bare identifier needs no parentheses, which is what says the surprise above # belongs to the DOT rather than to `!'. q('truth_not_value', '!text') q('truth_not_object', '!falsy') # EQUALITY SPECIAL-CASES 0/1 AGAINST true/false, because Python's `1 == True' is # true and JMESPath's is not. `_is_special_number_case' exists for this alone, # and these two cases are the whole of its observable effect. q('truth_one_eq_true', '`1` == `true`') q('truth_zero_eq_false', '`0` == `false`') q('truth_true_eq_true', '`true` == `true`') # --------------------------------------------------- the built-in functions -- # The specification's function library. Each is called over the fixture with a # literal expected value, because the failure a spot check passes is a function # table built from `_func_'-prefixed methods at class-definition time -- if the # metaclass did not run, EVERY name below is unknown at once, and if one # decorator were lost only that one is. q('fn_length_str', 'length(text)') q('fn_length_array', 'length(numbers)') q('fn_length_obj', 'length(people)') q('fn_keys', 'keys(people)') q('fn_values', 'values(people)') q('fn_sort_numbers', 'sort(numbers)') q('fn_sort_strings', 'sort(words)') q('fn_sort_by', 'sort_by(machines, &cpu)[*].name') q('fn_min_by', 'min_by(machines, &cpu).name') q('fn_max_by', 'max_by(machines, &cpu).name') q('fn_to_string_number', 'to_string(`42`)') q('fn_to_string_object', 'to_string(people.bob)') # `to_string' of a STRING is the identity, not a re-quoted string. That pair is # what says the function is not `json.dumps' applied blindly. q('fn_to_string_string', 'to_string(text)') q('fn_to_number_string', 'to_number(`"42"`)') q('fn_to_number_float', 'to_number(`"1.5"`)') # `to_number' RETURNS NULL rather than raising for an unconvertible value, and # for a bool, a list or an object -- the only function here whose failure path is # a value. q('fn_to_number_garbage', 'to_number(`"nope"`)') q('fn_to_number_bool', 'to_number(`true`)') q('fn_to_number_array', 'to_number(numbers)') q('fn_type_string', 'type(text)') q('fn_type_number', 'type(`1`)') q('fn_type_array', 'type(numbers)') q('fn_type_object', 'type(people)') q('fn_type_null', 'type(falsy.null)') q('fn_type_boolean', 'type(falsy.false)') q('fn_join', 'join(`", "`, words)') q('fn_map', 'map(&name, machines)') # `map' KEEPS the nulls a projection would drop -- same document, same missing # key, and the pair with `proj_drops_null' above is the clearest statement of # what a projection actually does. q('fn_map_keeps_null', 'map(&state, machines)') q('fn_contains_array', 'contains(words, `"alpha"`)') q('fn_contains_string', 'contains(text, `"world"`)') q('fn_contains_absent', 'contains(words, `"zulu"`)') q('fn_not_null_first', 'not_null(falsy.null, falsy.zero, text)') q('fn_not_null_all_null', 'not_null(falsy.null, nosuchkey)') # The remaining specification functions, each once: they are cheap and a table # with holes in it is a table nobody can read as coverage. q('fn_abs', 'abs(`-3`)') q('fn_avg', 'avg(numbers)') q('fn_ceil', 'ceil(`1.2`)') q('fn_floor', 'floor(`1.8`)') q('fn_max', 'max(numbers)') q('fn_min', 'min(numbers)') q('fn_sum', 'sum(numbers)') q('fn_merge', 'merge(`{"a": 1}`, `{"b": 2}`)') q('fn_reverse_array', 'reverse(numbers)') q('fn_reverse_string', 'reverse(text)') q('fn_starts_with', 'starts_with(text, `"hello"`)') q('fn_ends_with', 'ends_with(text, `"world"`)') q('fn_to_array_scalar', 'to_array(`1`)') q('fn_to_array_array', 'to_array(numbers)') # ------------------------------------------------------- compile() ----------- # `compile()' returns a REUSABLE parsed expression whose `search()' must agree # with the one-shot call. It is the whole of the package's performance story -- # a long-running caller parses once -- and the case that would catch a parsed # result that captured the first document it was applied to. _compiled = jmespath.compile('locations[*].name') emit('compile_type', type(_compiled).__name__) emit('compile_search', j(_compiled.search(DOC))) emit('compile_matches_oneshot', 'yes' if _compiled.search(DOC) == jmespath.search('locations[*].name', DOC) else 'no') # ...and REUSED against a SECOND document, which is what says the expression # holds no state from the first. emit('compile_reused', j(_compiled.search( {"locations": [{"name": "Reused"}]}))) # The parsed AST is reachable and is the plain dict `ast.py' builds, which is # what `jp.py --ast' prints. emit('compile_ast_type', j(sorted(_compiled.parsed.keys()))) # ------------------------------------------------------ the failure paths ---- # WHICH EXCEPTION, by name. These are the branches a caller writes, so the type # is the contract; the message is not, and is not emitted. def raises(key, expression, data=DOC): try: result = jmespath.search(expression, data) emit(key, 'no-raise:%s' % j(result)) except Exception as exc: emit(key, type(exc).__name__) # A character the lexer cannot start a token with. raises('raise_lexer', '@#$') # A well-formed prefix that simply stops -- a DIFFERENT exception from the one # above, and both are ParseError subclasses, which is what a caller catches. raises('raise_incomplete', 'foo[') # A token that is legal on its own and illegal there. raises('raise_parse', 'foo]') # The empty expression has its own exception type. raises('raise_empty', '') # An unterminated quoted identifier -- a lexer failure inside a construct rather # than at a stray character. raises('raise_unterminated_quote', '"unterminated') # A function that does not exist. raises('raise_unknown_function', 'nosuchfunction(@)') # Too few and too many arguments are DIFFERENT exceptions from a wrong type, and # from each other where the function is variadic. raises('raise_arity_few', 'length()') raises('raise_arity_many', 'length(text, text)') raises('raise_variadic_arity', 'not_null()') # THE WRONG TYPE RAISES RATHER THAN RETURNING NULL. This is the case the ticket # names, and it is the one a caller is most likely to have assumed the other way # round: `length' of a number is an error, not zero and not null. raises('raise_type_length', 'length(`3`)') raises('raise_type_join', 'join(`", "`, numbers)') raises('raise_type_sort', 'sort(machines)') raises('raise_type_sort_by', 'sort_by(machines, &state)') # ...and every one of them is a JMESPathError, which is the base class a caller # catches. The exception to that is `cmp_mixed_types' above, and this pair is # what makes the exception visible as one. def is_jmespath_error(key, expression, data=DOC): try: jmespath.search(expression, data) emit(key, 'no-raise') except jexc.JMESPathError: emit(key, 'yes') except Exception as exc: emit(key, 'no:%s' % type(exc).__name__) is_jmespath_error('caught_as_jmespath_lexer', '@#$') is_jmespath_error('caught_as_jmespath_type', 'length(`3`)') is_jmespath_error('caught_as_jmespath_mixed_cmp', '`"a"` < `1`') # Every one of those exception classes derives from ValueError, which is what a # caller that predates the package's own hierarchy is catching. emit('exc_base_is_valueerror', 'yes' if issubclass(jexc.JMESPathError, ValueError) else 'no') # ------------------------------------------------------------ Options -------- # `Options(dict_cls=)' picks the class a MULTISELECT HASH builds, which is the # package's one documented extension point for output shape. from collections import OrderedDict _opts = jmespath.Options(dict_cls=OrderedDict) _multi = jmespath.search('locations[0].{b: state, a: name}', DOC, options=_opts) emit('options_dict_cls', type(_multi).__name__) # ...and the ORDER is the expression's, which is the reason to ask for an # OrderedDict at all. emit('options_dict_order', j(list(_multi.keys()))) # ------------------------------------------------------- literals and JSON --- # A `` `...` `` literal is parsed as JSON, so every JSON type is expressible in # an expression rather than only in the document. q('lit_number', '`1.5`') q('lit_string', '`"literal"`') q('lit_true', '`true`') q('lit_null_type', 'type(`null`)') q('lit_array', '`[1,2]`') q('lit_object', '`{"k":"v"}`') # A RAW STRING literal `'...'` is NOT JSON and needs no escaping -- the # construct 1.0 added for exactly the quoting pain the case above shows. q('lit_raw_string', "'raw string'") # A quoted IDENTIFIER is a different construct from a string literal: it selects # a key whose name is not a bare identifier. q('quoted_identifier', '"a key"', {"a key": "found"}) # NON-ASCII SURVIVES THE ROUND TRIP -- emitted as code points, never as the # characters, so the value does not depend on the encoding stdout is in. _uni = jmespath.search('k', {"k": "café"}) emit('unicode_codepoints', ','.join(str(ord(c)) for c in _uni)) # --------------------------------------------------------- the parser cache -- # The Parser caches the 128 most recent expressions on the CLASS, which means a # second parse of the same text is the same object. It is also process-global # state, so `purge()' exists; both are asserted because a caller measuring parse # cost is measuring this. from jmespath import parser as _parser _parser.Parser.purge() _first = _parser.Parser().parse('locations[*].name') _second = _parser.Parser().parse('locations[*].name') emit('cache_hit_is_same_object', 'yes' if _first is _second else 'no') emit('cache_max_size', _parser.Parser._MAX_SIZE) _parser.Parser.purge() emit('cache_purged_empty', 'yes' if not _parser.Parser._CACHE else 'no') emit('probe_done', 'yes') emit('emitted_count', len(emitted) + 1)