| #!/usr/bin/env python3 |
| |
| # Copyright 2023 Nordic Semiconductor ASA |
| # SPDX-License-Identifier: Apache-2.0 |
| |
| from collections import defaultdict |
| from dataclasses import dataclass |
| from pathlib import Path |
| from typing import Any, Callable, Dict, List, Optional, Set, Union |
| import argparse |
| import contextlib |
| import glob |
| import os |
| import subprocess |
| import sys |
| import tempfile |
| |
| # TODO: include changes to child bindings |
| |
| HERE = Path(__file__).parent.resolve() |
| ZEPHYR_BASE = HERE.parent.parent |
| SCRIPTS = ZEPHYR_BASE / 'scripts' |
| |
| sys.path.insert(0, str(SCRIPTS / 'dts' / 'python-devicetree' / 'src')) |
| |
| from devicetree.edtlib import Binding, bindings_from_paths, load_vendor_prefixes_txt |
| |
| # The Compat type is a (compatible, on_bus) pair, which is used as a |
| # lookup key for bindings. The name "compat" matches edtlib's internal |
| # variable for this; it's a bit of a misnomer, but let's be |
| # consistent. |
| @dataclass |
| class Compat: |
| compatible: str |
| on_bus: Optional[str] |
| |
| def __hash__(self): |
| return hash((self.compatible, self.on_bus)) |
| |
| class BindingChange: |
| '''Marker type for an individual change that happened to a |
| binding between the start and end commits. See subclasses |
| below for concrete changes. |
| ''' |
| |
| Compat2Binding = Dict[Compat, Binding] |
| Binding2Changes = Dict[Binding, List[BindingChange]] |
| |
| @dataclass |
| class Changes: |
| '''Container for all the changes that happened between the |
| start and end commits.''' |
| |
| vnds: List[str] |
| vnd2added: Dict[str, Compat2Binding] |
| vnd2removed: Dict[str, Compat2Binding] |
| vnd2changes: Dict[str, Binding2Changes] |
| |
| @dataclass |
| class ModifiedSpecifier2Cells(BindingChange): |
| space: str |
| start: List[str] |
| end: List[str] |
| |
| @dataclass |
| class ModifiedBuses(BindingChange): |
| start: List[str] |
| end: List[str] |
| |
| @dataclass |
| class AddedProperty(BindingChange): |
| property: str |
| |
| @dataclass |
| class RemovedProperty(BindingChange): |
| property: str |
| |
| @dataclass |
| class ModifiedPropertyType(BindingChange): |
| property: str |
| start: str |
| end: str |
| |
| @dataclass |
| class ModifiedPropertyEnum(BindingChange): |
| property: str |
| start: Any |
| end: Any |
| |
| @dataclass |
| class ModifiedPropertyConst(BindingChange): |
| property: str |
| start: Any |
| end: Any |
| |
| @dataclass |
| class ModifiedPropertyDefault(BindingChange): |
| property: str |
| start: Any |
| end: Any |
| |
| @dataclass |
| class ModifiedPropertyDeprecated(BindingChange): |
| property: str |
| start: bool |
| end: bool |
| |
| @dataclass |
| class ModifiedPropertyRequired(BindingChange): |
| property: str |
| start: bool |
| end: bool |
| |
| def get_changes_between( |
| compat2binding_start: Compat2Binding, |
| compat2binding_end: Compat2Binding |
| ) -> Changes: |
| vnd2added: Dict[str, Compat2Binding] = \ |
| group_compat2binding_by_vnd({ |
| compat: compat2binding_end[compat] |
| for compat in compat2binding_end |
| if compat not in compat2binding_start |
| }) |
| |
| vnd2removed: Dict[str, Compat2Binding] = \ |
| group_compat2binding_by_vnd({ |
| compat: compat2binding_start[compat] |
| for compat in compat2binding_start |
| if compat not in compat2binding_end |
| }) |
| |
| vnd2changes = group_binding2changes_by_vnd( |
| get_binding2changes(compat2binding_start, |
| compat2binding_end)) |
| |
| vnds_set: Set[str] = set() |
| vnds_set.update(set(vnd2added.keys()), |
| set(vnd2removed.keys()), |
| set(vnd2changes.keys())) |
| |
| return Changes(vnds=sorted(vnds_set), |
| vnd2added=vnd2added, |
| vnd2removed=vnd2removed, |
| vnd2changes=vnd2changes) |
| |
| def group_compat2binding_by_vnd( |
| compat2binding: Compat2Binding |
| ) -> Dict[str, Compat2Binding]: |
| '''Convert *compat2binding* to a dict mapping vendor prefixes |
| to the subset of *compat2binding* with that vendor prefix.''' |
| ret: Dict[str, Compat2Binding] = defaultdict(dict) |
| |
| for compat, binding in compat2binding.items(): |
| ret[get_vnd(binding.compatible)][compat] = binding |
| |
| return ret |
| |
| def group_binding2changes_by_vnd( |
| binding2changes: Binding2Changes |
| ) -> Dict[str, Binding2Changes]: |
| '''Convert *binding2chages* to a dict mapping vendor prefixes |
| to the subset of *binding2changes* with that vendor prefix.''' |
| ret: Dict[str, Binding2Changes] = defaultdict(dict) |
| |
| for binding, changes in binding2changes.items(): |
| ret[get_vnd(binding.compatible)][binding] = changes |
| |
| return ret |
| |
| def get_vnd(compatible: str) -> str: |
| '''Return the vendor prefix or the empty string.''' |
| if ',' not in compatible: |
| return '' |
| |
| return compatible.split(',')[0] |
| |
| def get_binding2changes( |
| compat2binding_start: Compat2Binding, |
| compat2binding_end: Compat2Binding |
| ) -> Binding2Changes: |
| ret: Binding2Changes = {} |
| |
| for compat, binding in compat2binding_end.items(): |
| if compat not in compat2binding_start: |
| continue |
| |
| binding_start = compat2binding_start[compat] |
| binding_end = compat2binding_end[compat] |
| |
| binding_changes: List[BindingChange] = \ |
| get_binding_changes(binding_start, binding_end) |
| if binding_changes: |
| ret[binding] = binding_changes |
| |
| return ret |
| |
| def get_binding_changes( |
| binding_start: Binding, |
| binding_end: Binding |
| ) -> List[BindingChange]: |
| '''Enumerate the changes to a binding given its start and end values.''' |
| ret: List[BindingChange] = [] |
| |
| assert binding_start.compatible == binding_end.compatible |
| assert binding_start.on_bus == binding_end.on_bus |
| |
| common_props: Set[str] = set(binding_start.prop2specs).intersection( |
| set(binding_end.prop2specs)) |
| |
| ret.extend(get_modified_specifier2cells(binding_start, binding_end)) |
| ret.extend(get_modified_buses(binding_start, binding_end)) |
| ret.extend(get_added_properties(binding_start, binding_end)) |
| ret.extend(get_removed_properties(binding_start, binding_end)) |
| ret.extend(get_modified_property_type(binding_start, binding_end, |
| common_props)) |
| ret.extend(get_modified_property_enum(binding_start, binding_end, |
| common_props)) |
| ret.extend(get_modified_property_const(binding_start, binding_end, |
| common_props)) |
| ret.extend(get_modified_property_default(binding_start, binding_end, |
| common_props)) |
| ret.extend(get_modified_property_deprecated(binding_start, binding_end, |
| common_props)) |
| ret.extend(get_modified_property_required(binding_start, binding_end, |
| common_props)) |
| |
| return ret |
| |
| def get_modified_specifier2cells( |
| binding_start: Binding, |
| binding_end: Binding |
| ) -> List[BindingChange]: |
| ret: List[BindingChange] = [] |
| start = binding_start.specifier2cells |
| end = binding_end.specifier2cells |
| |
| if start == end: |
| return [] |
| |
| for space, cells_end in end.items(): |
| cells_start = start.get(space) |
| if cells_start != cells_end: |
| ret.append(ModifiedSpecifier2Cells(space, |
| start=cells_start, |
| end=cells_end)) |
| for space, cells_start in start.items(): |
| if space not in end: |
| ret.append(ModifiedSpecifier2Cells(space, |
| start=cells_start, |
| end=None)) |
| |
| return ret |
| |
| def get_modified_buses( |
| binding_start: Binding, |
| binding_end: Binding |
| ) -> List[BindingChange]: |
| start = binding_start.buses |
| end = binding_end.buses |
| |
| if start == end: |
| return [] |
| |
| return [ModifiedBuses(start=start, end=end)] |
| |
| def get_added_properties( |
| binding_start: Binding, |
| binding_end: Binding |
| ) -> List[BindingChange]: |
| return [AddedProperty(prop) for prop in binding_end.prop2specs |
| if prop not in binding_start.prop2specs] |
| |
| def get_removed_properties( |
| binding_start: Binding, |
| binding_end: Binding |
| ) -> List[BindingChange]: |
| return [RemovedProperty(prop) for prop in binding_start.prop2specs |
| if prop not in binding_end.prop2specs] |
| |
| def get_modified_property_type( |
| binding_start: Binding, |
| binding_end: Binding, |
| common_props: Set[str] |
| ) -> List[BindingChange]: |
| return get_modified_property_helper( |
| common_props, |
| lambda prop: binding_start.prop2specs[prop].type, |
| lambda prop: binding_end.prop2specs[prop].type, |
| ModifiedPropertyType) |
| |
| def get_modified_property_enum( |
| binding_start: Binding, |
| binding_end: Binding, |
| common_props: Set[str] |
| ) -> List[BindingChange]: |
| return get_modified_property_helper( |
| common_props, |
| lambda prop: binding_start.prop2specs[prop].enum, |
| lambda prop: binding_end.prop2specs[prop].enum, |
| ModifiedPropertyEnum) |
| |
| def get_modified_property_const( |
| binding_start: Binding, |
| binding_end: Binding, |
| common_props: Set[str] |
| ) -> List[BindingChange]: |
| return get_modified_property_helper( |
| common_props, |
| lambda prop: binding_start.prop2specs[prop].const, |
| lambda prop: binding_end.prop2specs[prop].const, |
| ModifiedPropertyConst) |
| |
| def get_modified_property_default( |
| binding_start: Binding, |
| binding_end: Binding, |
| common_props: Set[str] |
| ) -> List[BindingChange]: |
| return get_modified_property_helper( |
| common_props, |
| lambda prop: binding_start.prop2specs[prop].default, |
| lambda prop: binding_end.prop2specs[prop].default, |
| ModifiedPropertyDefault) |
| |
| def get_modified_property_deprecated( |
| binding_start: Binding, |
| binding_end: Binding, |
| common_props: Set[str] |
| ) -> List[BindingChange]: |
| return get_modified_property_helper( |
| common_props, |
| lambda prop: binding_start.prop2specs[prop].deprecated, |
| lambda prop: binding_end.prop2specs[prop].deprecated, |
| ModifiedPropertyDeprecated) |
| |
| def get_modified_property_required( |
| binding_start: Binding, |
| binding_end: Binding, |
| common_props: Set[str] |
| ) -> List[BindingChange]: |
| return get_modified_property_helper( |
| common_props, |
| lambda prop: binding_start.prop2specs[prop].required, |
| lambda prop: binding_end.prop2specs[prop].required, |
| ModifiedPropertyRequired) |
| |
| def get_modified_property_helper( |
| common_props: Set[str], |
| start_fn: Callable[[str], Any], |
| end_fn: Callable[[str], Any], |
| change_constructor: Callable[[str, Any, Any], BindingChange] |
| ) -> List[BindingChange]: |
| |
| ret = [] |
| for prop in common_props: |
| start = start_fn(prop) |
| end = end_fn(prop) |
| if start != end: |
| ret.append(change_constructor(prop, start, end)) |
| return ret |
| |
| def load_compat2binding(commit: str) -> Compat2Binding: |
| '''Load a map from compatible to binding with that compatible, |
| based on the bindings in zephyr at the given commit.''' |
| |
| @contextlib.contextmanager |
| def git_worktree(directory: os.PathLike, commit: str): |
| fspath = os.fspath(directory) |
| subprocess.run(['git', 'worktree', 'add', '--detach', fspath, commit], |
| check=True) |
| yield |
| print('removing worktree...') |
| subprocess.run(['git', 'worktree', 'remove', fspath], check=True) |
| |
| ret: Compat2Binding = {} |
| with tempfile.TemporaryDirectory(prefix='dt_bindings_worktree') as tmpdir: |
| with git_worktree(tmpdir, commit): |
| tmpdir_bindings = Path(tmpdir) / 'dts' / 'bindings' |
| binding_files = [] |
| binding_files.extend(glob.glob(f'{tmpdir_bindings}/**/*.yml', |
| recursive=True)) |
| binding_files.extend(glob.glob(f'{tmpdir_bindings}/**/*.yaml', |
| recursive=True)) |
| bindings: List[Binding] = bindings_from_paths( |
| binding_files, ignore_errors=True) |
| for binding in bindings: |
| compat = Compat(binding.compatible, binding.on_bus) |
| assert compat not in ret |
| ret[compat] = binding |
| |
| return ret |
| |
| def compatible_sort_key(data: Union[Compat, Binding]) -> str: |
| '''Sort key used by Printer.''' |
| return (data.compatible, data.on_bus or '') |
| |
| class Printer: |
| '''Helper class for formatting output.''' |
| |
| def __init__(self, outfile): |
| self.outfile = outfile |
| self.vnd2vendor_name = load_vendor_prefixes_txt( |
| ZEPHYR_BASE / 'dts' / 'bindings' / 'vendor-prefixes.txt') |
| |
| def print(self, *args, **kwargs): |
| kwargs['file'] = self.outfile |
| print(*args, **kwargs) |
| |
| def print_changes(self, changes: Changes): |
| for vnd in changes.vnds: |
| if vnd: |
| vnd_fmt = f' ({vnd})' |
| else: |
| vnd_fmt = '' |
| self.print(f'* {self.vendor_name(vnd)}{vnd_fmt}:\n') |
| |
| added = changes.vnd2added[vnd] |
| if added: |
| self.print(' * New bindings:\n') |
| self.print_compat2binding( |
| added, |
| lambda binding: f':dtcompatible:`{binding.compatible}`' |
| ) |
| |
| removed = changes.vnd2removed[vnd] |
| if removed: |
| self.print(' * Removed bindings:\n') |
| self.print_compat2binding( |
| removed, |
| lambda binding: f'``{binding.compatible}``' |
| ) |
| |
| modified = changes.vnd2changes[vnd] |
| if modified: |
| self.print(' * Modified bindings:\n') |
| self.print_binding2changes(modified) |
| |
| def print_compat2binding( |
| self, |
| compat2binding: Compat2Binding, |
| formatter: Callable[[Binding], str] |
| ) -> None: |
| for compat in sorted(compat2binding, key=compatible_sort_key): |
| self.print(f' * {formatter(compat2binding[compat])}') |
| self.print() |
| |
| def print_binding2changes(self, binding2changes: Binding2Changes) -> None: |
| for binding, changes in binding2changes.items(): |
| on_bus = f' (on {binding.on_bus} bus)' if binding.on_bus else '' |
| self.print(f' * :dtcompatible:`{binding.compatible}`{on_bus}:\n') |
| for change in changes: |
| self.print_change(change) |
| self.print() |
| |
| def print_change(self, change: BindingChange) -> None: |
| def print(msg): |
| self.print(f' * {msg}') |
| def print_prop_change(details): |
| print(f'property ``{change.property}`` {details} changed from ' |
| f'{change.start} to {change.end}') |
| if isinstance(change, ModifiedSpecifier2Cells): |
| print(f'specifier cells for space "{change.space}" ' |
| f'are now named: {change.end} (old value: {change.start})') |
| elif isinstance(change, ModifiedBuses): |
| print(f'bus list changed from {change.start} to {change.end}') |
| elif isinstance(change, AddedProperty): |
| print(f'new property: ``{change.property}``') |
| elif isinstance(change, RemovedProperty): |
| print(f'removed property: ``{change.property}``') |
| elif isinstance(change, ModifiedPropertyType): |
| print_prop_change('type') |
| elif isinstance(change, ModifiedPropertyEnum): |
| print_prop_change('enum value') |
| elif isinstance(change, ModifiedPropertyConst): |
| print_prop_change('const value') |
| elif isinstance(change, ModifiedPropertyDefault): |
| print_prop_change('default value') |
| elif isinstance(change, ModifiedPropertyDeprecated): |
| print_prop_change('deprecation status') |
| elif isinstance(change, ModifiedPropertyRequired): |
| if not change.start and change.end: |
| print(f'property ``{change.property}`` is now required') |
| else: |
| print(f'property ``{change.property}`` is no longer required') |
| else: |
| raise ValueError(f'unknown type for {change}: {type(change)}') |
| |
| def vendor_name(self, vnd: str) -> str: |
| # Necessary due to the patch for openthread. |
| |
| if vnd == 'openthread': |
| # FIXME: we have to go beyond the dict since this |
| # compatible isn't in vendor-prefixes.txt, but we have |
| # binding(s) for it. We need to fix this in CI by |
| # rejecting unknown vendors in a bindings check. |
| return 'OpenThread' |
| if vnd == '': |
| return 'Generic or vendor-independent' |
| return self.vnd2vendor_name[vnd] |
| |
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser( |
| allow_abbrev=False, |
| description=''' |
| Print human-readable descriptions of changes to devicetree |
| bindings between two commits, in .rst format suitable for copy/pasting |
| into the release notes. |
| ''', |
| formatter_class=argparse.RawDescriptionHelpFormatter |
| ) |
| parser.add_argument('start', metavar='START-COMMIT', |
| help='''what you want to compare bindings against |
| (typically the previous release's tag)''') |
| parser.add_argument('end', metavar='END-COMMIT', |
| help='''what you want to know bindings changes for |
| (typically 'main')''') |
| parser.add_argument('file', help='where to write the .rst output to') |
| return parser.parse_args() |
| |
| def main(): |
| args = parse_args() |
| |
| compat2binding_start = load_compat2binding(args.start) |
| compat2binding_end = load_compat2binding(args.end) |
| changes = get_changes_between(compat2binding_start, |
| compat2binding_end) |
| |
| with open(args.file, 'w') as outfile: |
| Printer(outfile).print_changes(changes) |
| |
| if __name__ == '__main__': |
| main() |