blob: c28c0e640b24d38c2d615a81ea20074e9171dfb7 [file] [log] [blame]
#!/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()