Repository: bokeh/bokeh

Issue:
Compare Public API Across Versions



Discussion:

Resolving patch:
diff --git a/bokeh/util/api_crawler.py b/bokeh/util/api_crawler.py
new file mode 100644
--- /dev/null
+++ b/bokeh/util/api_crawler.py
@@ -0,0 +1,233 @@
+import ast, os, copy
+
+__all__ = ["api_crawler", "differ"]
+
+
+class APICrawler(object):
+    """
+    Api Crawler: Given a directory, it will use AST to crawl through each of the
+    File and produce AST nodes, which can then be parsed into a representation
+    of the structure of that file. The execution...
+    """
+
+    # Exclude these folders from search.
+    exclude = ("tests", "static", "sampledata", "mplexplorer")
+
+
+    # Point the crawler at this directory.
+    def __init__(self, directory):
+        self.directory = directory
+
+    def is_public(self, name):
+        # Determines whether a given function or class is public or private. Names
+        # with underscores are not allowed unless the name is "__init__"
+        return not name.startswith("_", 0, 1) or (name.startswith("__") and
+                name.endswith("__"))
+
+    def is_toplevel_function(self, ast_node):
+        # Checks whether a node is a top level function. Checks whether it is an AST
+        # function instance. Also uses column offset, i.e. whether the function has
+        # any tabs or spaces before it.
+        return isinstance(ast_node, ast.FunctionDef) and ast_node.col_offset == 0
+
+    def is_class(self, ast_node):
+        # Checks whether a node is an instance of an ast Class
+        return isinstance(ast_node, ast.ClassDef)
+
+    def get_classes(self, source):
+        # For a given source, walk through the file looking for classes, and assign
+        # the associated methods. Pass them as a dictionary containing each class
+        # and its methods.
+        parsed = ast.parse(source)
+        classes = [node for node in ast.walk(parsed) if self.is_class(node) and self.is_public(node.name)]
+        class_defs = {}
+        for x in classes:
+            class_defs[x.name] = {}
+            methods = []
+            for y in x.body:
+                if isinstance(y, ast.FunctionDef) and self.is_public(y.name):
+                    methods.append(y.name)
+            class_defs[x.name]["methods"] = methods
+        return class_defs
+
+    def get_functions(self, source):
+        # For a given source, look for functions and return those functions as a list.
+        parsed = ast.parse(source)
+        functions = [node.name for node in ast.walk(parsed) if self.is_toplevel_function(node) and self.is_public(node.name)]
+        return functions
+
+    def get_filenames(self, directory):
+        # Go through the file tree and grab the filnames for each file found.
+        # Assign these to an array and return that array.
+        files = []
+        tree = os.walk(directory, topdown=True, followlinks=False)
+        for dirpath, dirnames, filenames in tree:
+            for folder in self.exclude:
+                if folder in dirpath:
+                    break
+            else:
+                for name in filenames:
+                    if name.endswith(".py") and not name.startswith("_"):
+                        files.append(os.path.join(dirpath, name))
+        return files
+
+    def get_files_dict(self, filenames):
+        # Using a list of filenames, open each file and parse functions and classes
+        # out of the source file.
+        files_dict = {}
+        for x in filenames:
+            with open(x, "r") as f:
+                source = f.read()
+                files_dict[x] = {"classes": {}, "functions": []}
+                files_dict[x]["classes"] = self.get_classes(source)
+                files_dict[x]["functions"] = self.get_functions(source)
+        return files_dict
+
+    def get_crawl_dict(self):
+        # Generate the crawl information using get_filenames as an argument to
+        # get_dict_files
+        files = self.get_filenames(self.directory)
+        files_dict = self.get_files_dict(files)
+        return files_dict
+
+
+class Differ(object):
+
+    def __init__(self, former, latter):
+        self.former = former
+        self.latter = latter
+        self._additions = False
+        self._operation = self.diff_operation
+
+    @property
+    def additions(self):
+        return self._additions
+
+    @additions.setter
+    def additions(self, value):
+        # If measuring additions instead of deletions, automatically change operator.
+        self._additions = value
+        if value:
+            self._operation = self.combinaton_diff_operation
+        else:
+            self._operation = self.diff_operation
+
+
+    def diff_operation(self, a, b):
+        # Use this function to find the difference between two sets of dictionary
+        # keys. Uses the set difference operation. Called by the diff_modules
+        # function.
+        return list(a - b)
+
+    def combinaton_diff_operation(self, a, b):
+        # Use a combination of symmetric_difference and difference to return a list
+        # of items additions through the crawl. Allows you to get additions items instead of
+        # removed items. Would produce the same result if you switched the
+        # positions of the former and latter versions. Much more natural and
+        # easy control using the different set operators.
+        return list((a ^ b) - a)
+
+    def diff_files(self):
+        diff = {}
+        intersection = {}
+
+        intersection.update(self.latter)
+        intersection.update(self.former)
+        files_diff = self._operation(set(self.former), set(self.latter))
+
+        # Diff files
+        for x in intersection.keys():
+            if x in files_diff:
+                diff[x] = {}
+        return intersection, diff
+
+    def diff_functions_classes(self, diff, intersection):
+        for x in intersection.keys():
+            former_items = self.former.get(x)
+            latter_items = self.latter.get(x)
+            if former_items and latter_items:
+                function_diff = self._operation(
+                    set(former_items["functions"]),
+                    set(latter_items["functions"])
+                )
+                class_diff = self._operation(
+                    set(list(former_items["classes"].keys())),
+                    set(list(latter_items["classes"].keys()))
+                )
+                if function_diff or list(class_diff):
+                    diff[x]= copy.deepcopy(intersection[x])
+                    if list(class_diff):
+                        diff_dict = {y: {} for y in class_diff}
+                        diff[x]["classes"] = diff_dict
+                    else:
+                        diff[x]["classes"] = {}
+
+                    if function_diff:
+                        diff[x]["functions"] = list(function_diff)
+                    else:
+                        diff[x]["functions"] = []
+        return diff
+
+    def diff_methods(self, diff, intersection):
+        for x in intersection.keys():
+            former_classes = self.former[x].get("classes") if self.former.get(x) else {}
+            latter_classes = self.latter[x].get("classes") if self.latter.get(x) else {}
+            if former_classes and latter_classes:
+                for y in intersection[x]["classes"]:
+                    # Prevent NoneType errors by returning empty dict.
+                    former_methods = self.former[x]["classes"].get(y, {})
+                    latter_methods = self.latter[x]["classes"].get(y, {})
+                    if former_methods.get("methods") and latter_methods.get("methods"):
+                        former_values = set(list(former_methods.values())[0])
+                        latter_values = set(list(latter_methods.values())[0])
+                        methods_diff = self._operation(former_values, latter_values)
+                        if methods_diff:
+                            if not diff.get(x):
+                                diff[x] = {}
+                                diff[x]["classes"] = {}
+                            diff[x]["classes"][y] = copy.deepcopy(intersection[x]["classes"][y])
+                            diff[x]["classes"][y]["methods"] = methods_diff
+        return diff
+
+    def diff_modules(self):
+        intersection, diff = self.diff_files()
+        diff = self.diff_functions_classes(diff, intersection)
+        diff = self.diff_methods(diff, intersection)
+        return diff
+
+    def pretty_diff(self, diff):
+        parsed_diff = []
+        if self.additions:
+            method = "ADDED"
+        else:
+            method = "DELETED"
+        for x in diff.keys():
+            formatted_string = "%s %s" % (method, os.path.splitext(x.replace("/", "."))[0])
+            if diff[x].values():
+                for y in diff[x].values():
+                    if isinstance(y, dict) and y:
+                        for z in y.keys():
+                            if not y[z].values():
+                                parsed_diff.append("%s.%s" % (formatted_string, z))
+                            else:
+                                for a in y[z].values():
+                                    for b in a:
+                                        parsed_diff.append("%s.%s.%s" % (formatted_string, z, b))
+                    elif isinstance(y, list) and y:
+                        for z in y:
+                            class_string = "%s" % z
+                            parsed_diff.append("%s.%s" % (formatted_string, class_string))
+            else:
+                parsed_diff.insert(0, formatted_string)
+        return parsed_diff
+
+    def get_diff(self):
+        self.additions = False
+        removed = self.pretty_diff(self.diff_modules())
+        self.additions = True
+        added = self.pretty_diff(self.diff_modules())
+        return removed + added
+
+
+api_crawler = APICrawler
+differ = Differ
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
diff --git a/scripts/api_report.py b/scripts/api_report.py
new file mode 100644
--- /dev/null
+++ b/scripts/api_report.py
@@ -0,0 +1,29 @@
+from __future__ import absolute_import
+import ast, os, copy, subprocess, sys
+
+from bokeh.util.api_crawler import api_crawler, differ
+
+
+def diff_versions(old_version, new_version):
+    subprocess.call("git stash && git checkout tags/%s -- bokeh" % old_version, shell=True)
+    old = api_crawler("bokeh").get_crawl_dict()
+    subprocess.call("git checkout tags/%s -- bokeh" % new_version, shell=True)
+    new = api_crawler("bokeh").get_crawl_dict()
+
+    # Reset HEAD to initial state.
+    subprocess.call("git checkout HEAD -- bokeh", shell=True)
+    subprocess.call("git reset HEAD -- bokeh", shell=True)
+    subprocess.call("git stash apply", shell=True)
+
+    # Combine items removed and added into a single text file.
+    diff = differ(old, new)
+    pretty_diff = "\n".join(diff.get_diff()) + "\n"
+
+    return pretty_diff
+
+
+if __name__ == "__main__":
+    if len(sys.argv) >= 3:
+        sys.stdout.write(diff_versions(sys.argv[1], sys.argv[2]))
+    else:
+        print("Please provide an old version and a new version.")


Test patch:
diff --git a/bokeh/util/tests/test_api_crawler.py b/bokeh/util/tests/test_api_crawler.py
new file mode 100644
--- /dev/null
+++ b/bokeh/util/tests/test_api_crawler.py
@@ -0,0 +1,226 @@
+from __future__ import absolute_import
+import ast
+
+
+from ..api_crawler import api_crawler, differ
+
+
+sample_class = """
+class SampleClass:
+    def __init__(self):
+        pass
+"""
+
+sample_function = """
+def sample_function(self):
+    pass
+"""
+
+sample_code = """
+class SampleClass:
+    def __init__(self):
+        pass
+def sample_function(self):
+    pass
+"""
+
+
+class TestApiCrawler(object):
+    crawler = api_crawler("bokeh")
+
+    def test_get_crawl_dict(self):
+        crawl_dict = self.crawler.get_crawl_dict()
+        assert crawl_dict
+
+    def test_name_is_public(self):
+        filename = "_apple"
+        assert not self.crawler.is_public(filename)
+        filename = "__init__"
+        assert self.crawler.is_public(filename)
+        filename = "__getattribute__"
+        assert self.crawler.is_public(filename)
+        filename = "apple"
+        assert self.crawler.is_public(filename)
+
+    def test_filename_is_public(self):
+        # Should not crawl __init__.py files
+        filename = "__init__.py"
+        assert not self.crawler.is_public(filename)
+
+        filename = "_apple.py"
+        assert not self.crawler.is_public(filename)
+        filename = "apple.py"
+        assert self.crawler.is_public(filename)
+
+    def test_is_toplevel_function(self):
+        parsed_function = ast.parse(sample_function).body[0]
+        assert self.crawler.is_toplevel_function(parsed_function)
+
+    def test_is_class(self):
+        parsed_class = ast.parse(sample_class).body[0]
+        assert self.crawler.is_class(parsed_class)
+
+    def test_get_functions(self):
+        functions = self.crawler.get_functions(sample_code)
+        assert "sample_function" in functions
+
+    def test_get_classes(self):
+        classes = self.crawler.get_classes(sample_code)
+        assert "SampleClass" in classes
+
+
+old_version = {
+    "models": {},
+    "bands": {
+        "classes": {
+            "Radiohead": {
+                "methods": ["thom", "jonny", "colin", "ed", "phil"]
+            },
+            "Beatles": {
+                "methods": ["Here Comes the Sun"]
+            }
+        },
+        "functions": ["john", "paul", "ringo"]
+    }
+}
+
+new_version = {
+    "bands": {
+        "classes": {
+            "Radiohead": {
+                "methods": ["thom", "colin", "ed", "phil"]
+            },
+            "Pixies": {"methods": ["debaser"]}
+        },
+        "functions": ["john", "paul", "george"]
+    }
+}
+
+expected_diff = {
+    "models": {},
+    "bands": {
+        "classes": {
+            "Radiohead": {
+                "methods": ["jonny"]
+            },
+            "Beatles": {}
+        },
+        "functions": ["ringo"]
+    }
+}
+
+expected_additions = {
+    'bands': {
+        'classes': {
+            'Pixies': {}
+        },
+        'functions': ['george']
+    }
+}
+
+expected_parsed_diff = [
+    'DELETED models',
+    'DELETED bands.Radiohead.jonny',
+    'DELETED bands.Apple',
+    'DELETED bands.ringo',
+    'DELETED bands.Beatles'
+]
+
+expected_parsed_additions = [
+    'ADDED bands.george',
+    'ADDED bands.Pixies'
+]
+
+single_class_old = {
+    "bands": {"functions": [], "classes": {"Radiohead": {"methods": ["thom", "jonny", "colin", "ed", "phil"]}}}
+}
+
+single_class_new = {
+    "bands": {"functions": [], "classes": {"Radiohead": {"methods": ["thom", "colin", "ed", "phil"]}}}
+}
+
+expected_single_class = {
+    "bands": {"classes": {"Radiohead": {"methods": ["jonny"]}}}
+}
+
+
+class TestDiffer(object):
+    differ = differ(old_version, new_version)
+
+    def test_accurate_diff(self):
+        self.differ.additions = False
+        raw_diff = self.differ.diff_modules()
+        assert raw_diff == expected_diff
+
+    def test_catch_key_error(self):
+        self.differ.additions = False
+        self.differ.former = single_class_old
+        self.differ.latter = single_class_new
+        raw_diff = self.differ.diff_modules()
+        assert raw_diff == expected_single_class
+
+        self.differ.former = old_version
+        self.differ.latter = new_version
+
+    def test_get_diff(self):
+        diff = self.differ.get_diff()
+        expected_diff = expected_parsed_diff + expected_parsed_additions
+        for x in diff:
+            assert x in expected_diff
+
+    def test_diff_additions(self):
+        self.differ.additions = True
+        raw_diff = self.differ.diff_modules()
+        assert raw_diff == expected_additions
+
+    def test_removed_parsing(self):
+        self.differ.additions = False
+        raw_diff = self.differ.diff_modules()
+        raw_diff = self.differ.pretty_diff(raw_diff)
+        for x in raw_diff:
+            assert x in expected_parsed_diff
+
+    def test_additions_parsing(self):
+        self.differ.additions = True
+        raw_diff = self.differ.diff_modules()
+        raw_diff = self.differ.pretty_diff(raw_diff)
+        for x in raw_diff:
+            assert x in expected_parsed_additions
+
+    def test_operators(self):
+        a = {"one", "two", "three", "four"}
+        b = {"one", "two", "three", "five"}
+        assert self.differ.diff_operation(a, b) == ["four"]
+        assert self.differ.combinaton_diff_operation(a, b) == ["five"]
+
+    def test_diff_files(self):
+        self.differ.additions = False
+        intersection, diff = self.differ.diff_files()
+        assert list(diff.keys()) == ["models"]
+        for x in intersection.keys():
+            assert x in ["models", "bands"]
+        self.differ.additions = True
+        intersection, diff = self.differ.diff_files()
+        assert list(diff.keys()) == []
+        for x in intersection.keys():
+            assert x in ["models", "bands"]
+
+    def test_diff_classes_functions(self):
+        self.differ.additions = False
+        intersection, diff = self.differ.diff_files()
+        diff = self.differ.diff_functions_classes(diff, intersection)
+        assert diff["bands"]["functions"] == ["ringo"]
+        for x in diff["bands"]["classes"].keys():
+            assert x in expected_diff["bands"]["classes"].keys()
+        self.differ.additions = True
+        intersection, diff = self.differ.diff_files()
+        diff = self.differ.diff_functions_classes(diff, intersection)
+        assert diff["bands"]["functions"] == ["george"]
+        assert list(diff["bands"]["classes"].keys()) == ["Pixies"]
+
+    def test_diff_methods(self):
+        self.differ.additions = False
+        intersection, diff = self.differ.diff_files()
+        diff = self.differ.diff_functions_classes(diff, intersection)
+        diff = self.differ.diff_methods(diff, intersection)
+        assert diff["bands"]["classes"]["Radiohead"]["methods"] == ["jonny"]
diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py
new file mode 100644
diff --git a/scripts/tests/samples/sample_diff.txt b/scripts/tests/samples/sample_diff.txt
new file mode 100644
--- /dev/null
+++ b/scripts/tests/samples/sample_diff.txt
@@ -0,0 +1,93 @@
+DELETED bokeh.charts.stats.Bins.update
+DELETED bokeh.charts.stats.Bins.__init__
+DELETED bokeh.charts.data_source.ChartDataSource.create_attr_data
+DELETED bokeh.plotting.figure.Figure.ygrid
+DELETED bokeh.plotting.figure.Figure.yaxis
+DELETED bokeh.plotting.figure.Figure.legend
+DELETED bokeh.plotting.figure.Figure.grid
+DELETED bokeh.plotting.figure.Figure.xaxis
+DELETED bokeh.plotting.figure.Figure.xgrid
+DELETED bokeh.plotting.figure.Figure.axis
+DELETED bokeh.io.gridplot
+DELETED bokeh.models.tools.PreviewSaveTool
+DELETED bokeh.models.widgets.layouts.BaseBox
+DELETED bokeh.models.widgets.layouts.HBox
+DELETED bokeh.models.widgets.layouts.Layout
+DELETED bokeh.models.widgets.layouts.VBox
+DELETED bokeh.models.widgets.layouts.VBoxForm
+DELETED bokeh.application.handlers.script.ScriptHandler.failed
+DELETED bokeh.application.handlers.script.ScriptHandler.error_detail
+DELETED bokeh.application.handlers.script.ScriptHandler.url_path
+DELETED bokeh.application.handlers.script.ScriptHandler.modify_document
+DELETED bokeh.application.handlers.script.ScriptHandler.error
+DELETED bokeh.server.session.ServerSession.seconds_since_last_unsubscribe
+DELETED bokeh.models.sources.BlazeDataSource
+DELETED bokeh.models.plots.LayoutBox
+DELETED bokeh.models.plots.GridPlot
+ADDED bokeh.charts.glyphs.ArcGlyph
+ADDED bokeh.charts.attributes.AttrSpec.series
+ADDED bokeh.models.widgets.buttons.ButtonLike
+ADDED bokeh.charts.models.CompositeGlyph.df
+ADDED bokeh.charts.models.CompositeGlyph.data
+ADDED bokeh.charts.models.CompositeGlyph.__init__
+ADDED bokeh.models.tools.Tap
+ADDED bokeh.models.tools.ToolbarBox
+ADDED bokeh.models.tools.Scroll
+ADDED bokeh.models.tools.UndoTool
+ADDED bokeh.models.tools.Drag
+ADDED bokeh.models.tools.RedoTool
+ADDED bokeh.models.tools.Action
+ADDED bokeh.models.tools.ToolbarBase
+ADDED bokeh.models.tools.Inspection
+ADDED bokeh.models.tools.SaveTool
+ADDED bokeh.models.tools.Toolbar
+ADDED bokeh.models.annotations.LabelSet
+ADDED bokeh.models.annotations.Title
+ADDED bokeh.models.annotations.TextAnnotation
+ADDED bokeh.models.annotations.Arrow
+ADDED bokeh.models.annotations.Label
+ADDED bokeh.application.application.Application.safe_to_fork
+ADDED bokeh.application.application.Application.static_path
+ADDED bokeh.models.plots.Plot.title_standoff
+ADDED bokeh.models.plots.Plot.title_text_baseline
+ADDED bokeh.models.plots.Plot.title_text_font_style
+ADDED bokeh.models.plots.Plot.title_text_font_size
+ADDED bokeh.models.plots.Plot.title_text_alpha
+ADDED bokeh.models.plots.Plot.title_text_align
+ADDED bokeh.models.plots.Plot.yaxis
+ADDED bokeh.models.plots.Plot.title_text_font
+ADDED bokeh.models.plots.Plot.xgrid
+ADDED bokeh.models.plots.Plot.title_text_color
+ADDED bokeh.models.plots.Plot.grid
+ADDED bokeh.models.plots.Plot.xaxis
+ADDED bokeh.models.plots.Plot.responsive
+ADDED bokeh.models.plots.Plot.logo
+ADDED bokeh.models.plots.Plot.tools
+ADDED bokeh.models.plots.Plot.legend
+ADDED bokeh.models.plots.Plot.ygrid
+ADDED bokeh.models.plots.Plot.axis
+ADDED bokeh.document.without_document_lock
+ADDED bokeh.document.UnlockedDocumentProxy
+ADDED bokeh.document.Document.template
+ADDED bokeh.document.ColumnsStreamedEvent
+ADDED bokeh.charts.stats.BinnedStat
+ADDED bokeh.charts.stats.Histogram
+ADDED bokeh.charts.utils.color_in_equal_space
+ADDED bokeh.charts.utils.comp_glyphs_to_df
+ADDED bokeh.models.mappers.LogColorMapper
+ADDED bokeh.models.glyphs.Ellipse
+ADDED bokeh.server.session.ServerSession.milliseconds_since_last_unsubscribe
+ADDED bokeh.charts.builder.Builder.sort_legend
+ADDED bokeh.models.widgets.markups.Div
+ADDED bokeh.models.formatters.FuncTickFormatter
+ADDED bokeh.core.properties.TitleProp
+ADDED bokeh.core.properties.TimeDelta
+ADDED bokeh.server.tornado.match_host
+ADDED bokeh.server.tornado.BokehTornado.initialize
+ADDED bokeh.application.handlers.handler.Handler.static_path
+ADDED bokeh.charts.data_source.ChartDataSource.join_attrs
+ADDED bokeh.charts.data_source.ChartDataSource.attr_specs
+ADDED bokeh.charts.chart.Chart.ygrid
+ADDED bokeh.charts.chart.Chart.xgrid
+ADDED bokeh.charts.chart.Chart.legend
+ADDED bokeh.models.sources.ColumnDataSource.stream
diff --git a/scripts/tests/test_api_report.py b/scripts/tests/test_api_report.py
new file mode 100644
--- /dev/null
+++ b/scripts/tests/test_api_report.py
@@ -0,0 +1,13 @@
+from __future__ import absolute_import
+import os
+
+from ..api_report import diff_versions
+
+
+class TestParseModules(object):
+
+    def test_working_pipeline(self):
+        first = diff_versions("0.11.0", "0.12.0").split("\n")
+        with open("scripts/tests/samples/sample_diff.txt") as f:
+            second = f.read().split("\n")
+        assert not set(first) - set(second)