blob: 28d158912a805bb9d993db40e49417637bea7e8e [file] [log] [blame] [edit]
# Copyright 2025 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import collections.abc
import json
import typing
from typing import Iterable
Any = typing.Any
# JsonMatch (or None) will be produced by JsonGet.match().
# It will have attributes added to it: m.a instead of m["a"] as syntactic sugar.
# However, this can't be type-checked (a plain dictionary can't either),
# so type annotations should use type Any rather than JsonMatch to keep MyPy happy.
class JsonMatch:
def __repr__(self) -> str:
return f"JsonMatch<{vars(self)}>"
# Create a named singleton object to be used in match patterns,
# along with Any (=typing.Any) and None.
class Maybe:
pass
class JsonGet:
def __init__(
self, json_string: str | None = None, value: Any = None
) -> None:
"""Exactly one of json_string or value must be not None.
If it's json_string, that must be valid JSON."""
assert (json_string is not None or value is not None) and (
json_string is None or value is None
)
if json_string is not None:
self.j = json.loads(json_string)
if value is not None:
self.j = value
# if pattern matches the JSON data, calls callback(match) if given. `match` will be a JsonMatch.
# If pattern doesn't match, calls no_match() if given.
# Returns the match result.
def match(
self,
pattern: Any,
callback: collections.abc.Callable[[Any], None] | None = None,
no_match: collections.abc.Callable[[], None] | None = None,
) -> Any:
"""Matches the contained JSON value against a pattern.
Args:
pattern: The pattern to match. List and dict patterns can be nested/recursive.
- dict: Matches a dict. Keys in the pattern are matched against keys in the data.
keys not in the pattern are not added to the match object.
- `Any`: matches any value and fails if the key is missing.
- `Maybe`: matches any value, or a missing key, in which case the key will be
added to the JsonMatch with a value of None.
- `None`: fails the match if the key is in the root dict.
- any other value must match the value fetched from data by the key.
- list: Extracts matches from a list. The pattern list must have exactly one
element, which is the pattern to match against each element of the data.
- Any: matches any value; requires the key to be present.
- Maybe: matches any value; a missing key is added with value None.
- None: the match fails if the key is present.
- other value: matches that value.
callback: (optional) A function to call if the pattern matches. It will be called
with a JsonMatch object.
no_match: (optional) A function to call if the pattern does not match.
Returns:
The JsonMatch object if the pattern matches, or None otherwise.
Example:
j = JsonGet('{"a": 1, "b": 2}')
# Match if the JSON has a key "a" with value 1, and a key "b" with any value.
# The callback will be called with a JsonMatch object with attribute `a` set to 1 and
# `b` set to the value of "b".
j.match({"a": 1, "b": Any}, callback=lambda m: print(m.b)) # prints 2
# Match if the JSON has a key "a" with value 1, and a key "c" which
# may be missing or have any value.
j.match({"a": 1, "c": Maybe}, callback=lambda m: print(m.c)) # prints None
# Match if the JSON has a key "a" with value 1, and no key "b".
j.match({"a": 1, "b": None}, no_match=lambda: print("no match")) # prints "no match"
j = JsonGet('[1, 2, 3]')
# Match if the JSON is a list.
j.match([Any], callback=lambda m: print(m)) # prints [1, 2, 3]
j = JsonGet('[{"t": "a", "n": 1}, {"t": "a", "n": 2}, {"t": "b", "n": {"x": 5}}])
# Fetch the type "a" entries
j.match([{"t": "a", "n": Any}], lambda m: print([e.n for e in m])) # prints [1, 2]
# Recurse into dictionary
j.match([{"n": {"x": Any}}], lambda m: print(len(m), m[0].b.x)) # prints 1, 5
# No need to match all the way down
j.match([{"t": "b", "n": Any}], lambda m: print(m[0].n)) # prints {"x": 5}
"""
m = self._match_toplevel(pattern, self.j)
if m and callback:
callback(m)
if not m and no_match:
no_match()
return m
def _match_toplevel(self, pattern: Any, data: Any) -> Any:
if isinstance(pattern, dict) and isinstance(data, dict):
return self._match_dict(pattern, data)
elif isinstance(pattern, list) and isinstance(data, Iterable):
assert len(pattern) == 1
return self._match_list(pattern[0], data)
elif pattern is Any or pattern is Maybe or pattern == data:
return data
else:
return None
# Returns a list of items of data that match pattern.
def _match_list(self, pattern: Any, data: Iterable[Any]) -> Iterable[Any]:
return list(
filter(bool, [self._match_toplevel(pattern, i) for i in data])
)
# `pattern` is a dict, whose keys are matched to the root dict of `tree`.
# Values of `pattern`:
# - `Any` matches any value and fails if the key is missing.
# - `Maybe` matches any value, or a missing key, in which case the key will be
# added to the JsonMatch with a value of None.
# - `None` fails the match if the key is in the root dict.
# - any other value must match the value fetched from data by the key.
# If match fails, returns None.
# If match succeeds, returns a JsonMatch with fields corresponding to the filled-in pattern.
def _match_dict(
self, pattern: dict[str, Any], tree: dict[str, Any]
) -> Any: # -> JsonMatch | None:
if not isinstance(tree, dict):
return None
o = JsonMatch()
for k, v in pattern.items():
if v is None:
if k in tree:
return None
elif v is Maybe:
if k in tree:
matched = tree[k]
else:
matched = None
elif k not in tree:
return None
elif v == Any:
matched = tree[k]
else:
matched = self._match_toplevel(v, tree[k])
if matched is None:
return None
setattr(o, k, matched)
return o