1#! /usr/bin/env python3
2
3import argparse
4import datetime
5import os
6import pathlib
7import re
8import sys
9
10import jinja2
11
12def trim_pv(pv):
13    """
14    Strip anything after +git from the PV
15    """
16    return "".join(pv.partition("+git")[:2])
17
18def needs_update(version, upstream):
19    """
20    Do a dumb comparison to determine if the version needs to be updated.
21    """
22    if "+git" in version:
23        # strip +git and see if this is a post-release snapshot
24        version = version.replace("+git", "")
25    return version != upstream
26
27def safe_patches(patches):
28    for info in patches:
29        if info["status"] in ("Denied", "Pending", "Unknown"):
30            return False
31    return True
32
33def layer_path(layername: str, d) -> pathlib.Path:
34    """
35    Return the path to the specified layer, or None if the layer isn't present.
36    """
37    if not hasattr(layer_path, "cache"):
38        # Don't use functools.lru_cache as we don't want d changing to invalidate the cache
39        layer_path.cache = {}
40
41    if layername in layer_path.cache:
42        return layer_path.cache[layername]
43
44    bbpath = d.getVar("BBPATH").split(":")
45    pattern = d.getVar('BBFILE_PATTERN_' + layername)
46    for path in reversed(sorted(bbpath)):
47        if re.match(pattern, path + "/"):
48            layer_path.cache[layername] = pathlib.Path(path)
49            return layer_path.cache[layername]
50    return None
51
52def get_url_for_patch(layer: str, localpath: pathlib.Path, d) -> str:
53    relative = localpath.relative_to(layer_path(layer, d))
54
55    # TODO: use layerindexlib
56    # TODO: assumes default branch
57    if layer == "core":
58        return f"https://git.openembedded.org/openembedded-core/tree/meta/{relative}"
59    elif layer in ("meta-arm", "meta-arm-bsp", "arm-toolchain"):
60        return f"https://git.yoctoproject.org/meta-arm/tree/{layer}/{relative}"
61    else:
62        print(f"WARNING: Don't know web URL for layer {layer}", file=sys.stderr)
63        return None
64
65def extract_patch_info(src_uri, d):
66    """
67    Parse the specified patch entry from a SRC_URI and return (base name, layer name, status) tuple
68    """
69    import bb.fetch, bb.utils
70
71    info = {}
72    localpath = pathlib.Path(bb.fetch.decodeurl(src_uri)[2])
73    info["name"] = localpath.name
74    info["layer"] = bb.utils.get_file_layer(str(localpath), d)
75    info["url"] = get_url_for_patch(info["layer"], localpath, d)
76
77    status = "Unknown"
78    with open(localpath, errors="ignore") as f:
79        m = re.search(r"^[\t ]*Upstream[-_ ]Status:?[\t ]*(\w*)", f.read(), re.IGNORECASE | re.MULTILINE)
80        if m:
81            # TODO: validate
82            status = m.group(1)
83    info["status"] = status
84    return info
85
86def harvest_data(machines, recipes):
87    import bb.tinfoil
88    with bb.tinfoil.Tinfoil() as tinfoil:
89        tinfoil.prepare(config_only=True)
90        corepath = layer_path("core", tinfoil.config_data)
91        sys.path.append(os.path.join(corepath, "lib"))
92    import oe.recipeutils
93    import oe.patch
94
95    # Queue of recipes that we're still looking for upstream releases for
96    to_check = list(recipes)
97
98    # Upstream releases
99    upstreams = {}
100    # Machines to recipes to versions
101    versions = {}
102
103    for machine in machines:
104        print(f"Gathering data for {machine}...")
105        os.environ["MACHINE"] = machine
106        with bb.tinfoil.Tinfoil() as tinfoil:
107            versions[machine] = {}
108
109            tinfoil.prepare(quiet=2)
110            for recipe in recipes:
111                try:
112                    d = tinfoil.parse_recipe(recipe)
113                except bb.providers.NoProvider:
114                    continue
115
116                if recipe in to_check:
117                    try:
118                        info = oe.recipeutils.get_recipe_upstream_version(d)
119                        upstreams[recipe] = info["version"]
120                        to_check.remove(recipe)
121                    except (bb.providers.NoProvider, KeyError):
122                        pass
123
124                details = versions[machine][recipe] = {}
125                details["recipe"] = d.getVar("PN")
126                details["version"] = trim_pv(d.getVar("PV"))
127                details["fullversion"] = d.getVar("PV")
128                details["patches"] = [extract_patch_info(p, d) for p in oe.patch.src_patches(d)]
129                details["patched"] = bool(details["patches"])
130                details["patches_safe"] = safe_patches(details["patches"])
131
132    # Now backfill the upstream versions
133    for machine in versions:
134        for recipe in versions[machine]:
135            data = versions[machine][recipe]
136            data["upstream"] = upstreams[recipe]
137            data["needs_update"] = needs_update(data["version"], data["upstream"])
138    return upstreams, versions
139
140# TODO can this be inferred from the list of recipes in the layer
141recipes = ("virtual/kernel",
142           "scp-firmware",
143           "trusted-firmware-a",
144           "trusted-firmware-m",
145           "edk2-firmware",
146           "u-boot",
147           "optee-os",
148           "gcc-aarch64-none-elf-native",
149           "gcc-arm-none-eabi-native")
150
151
152class Format:
153    """
154    The name of this format
155    """
156    name = None
157    """
158    Registry of names to classes
159    """
160    registry = {}
161
162    def __init_subclass__(cls, **kwargs):
163        super().__init_subclass__(**kwargs)
164        assert cls.name
165        cls.registry[cls.name] = cls
166
167    @classmethod
168    def get_format(cls, name):
169        return cls.registry[name]()
170
171    def render(self, context, output: pathlib.Path):
172        pass
173
174    def get_template(self, name):
175        template_dir = os.path.dirname(os.path.abspath(__file__))
176        env = jinja2.Environment(
177            loader=jinja2.FileSystemLoader(template_dir),
178            extensions=['jinja2.ext.i18n'],
179            autoescape=jinja2.select_autoescape(),
180            trim_blocks=True,
181            lstrip_blocks=True
182        )
183
184        # We only need i18n for plurals
185        env.install_null_translations()
186
187        return env.get_template(name)
188
189class TextOverview(Format):
190    name = "overview.txt"
191
192    def render(self, context, output: pathlib.Path):
193        with open(output, "wt") as f:
194            f.write(self.get_template(f"machine-summary-overview.txt.jinja").render(context))
195
196class HtmlUpdates(Format):
197    name = "report"
198
199    def render(self, context, output: pathlib.Path):
200        if output.exists() and not output.is_dir():
201            print(f"{output} is not a directory", file=sys.stderr)
202            sys.exit(1)
203
204        if not output.exists():
205            output.mkdir(parents=True)
206
207        with open(output / "index.html", "wt") as f:
208            f.write(self.get_template(f"report-index.html.jinja").render(context))
209
210        subcontext = context.copy()
211        del subcontext["data"]
212        for machine, subdata in context["data"].items():
213            subcontext["machine"] = machine
214            subcontext["data"] = subdata
215            with open(output / f"{machine}.html", "wt") as f:
216                f.write(self.get_template(f"report-details.html.jinja").render(subcontext))
217
218if __name__ == "__main__":
219    parser = argparse.ArgumentParser(description="machine-summary")
220    parser.add_argument("machines", nargs="+", help="machine names", metavar="MACHINE")
221    parser.add_argument("-t", "--type", required=True, choices=Format.registry.keys())
222    parser.add_argument("-o", "--output", type=pathlib.Path, required=True)
223    args = parser.parse_args()
224
225    context = {}
226    # TODO: include git describe for meta-arm
227    context["timestamp"] = str(datetime.datetime.now().strftime("%c"))
228    context["recipes"] = sorted(recipes)
229    context["releases"], context["data"] = harvest_data(args.machines, recipes)
230
231    formatter = Format.get_format(args.type)
232    formatter.render(context, args.output)
233