PKzNNs7777frappuccino/__init__.py""" Frappuccino =========== Freeze your API. Frappucino allows you during development to make sure you haven't broken API. By first taking an imprint of your API, at one point in time and then compare it to the current project state it can warn you whether you have introduced incompatible changes, and list theses. You should (of course) integrate it in you CI to make sure you don't inadvertently break things. Example: ```python # old function def read(name, *, options=None): with open(name. 'rb') as f: return process(data) # new function def read(name_or_buffer, *, options=None): if isinstance(name, str): with open(name, 'rb') as f: data = f.read() else: data = name_or_buffer.read() return process(data) ``` There is a subtle breakage of API in the above, as you may not remember positional parameters can be use a keyword arguments. That is to say one of your customer may use: ```python read(name='dump.csv') ``` Hence changing the _name_ of the positional parameter from `name` to `name_or_buffer` is a change of API. There are a number of details like this one where you _may_ end up breaking API without realizing. It's hard to keep track of this when working on dev branches, unit test may not catch all of that. Frappuccino is there to help. """ __version__ = '0.0.2' import importlib import inspect import types import json import sys import re from .visitor import BaseVisitor from .logging import logger hexd = re.compile('0x[0-9a-f]+') def hexuniformify(s: str) -> str: """ Uniforming hex addresses to `0xffffff` to avoid difference between rerun. Difference may be due to object id varying in reprs. """ return hexd.sub('0xffffff', s) # likely unused code used for testing at some point def foo(): pass def sigfd(data): """ Try to convert a dump to a string human readable """ from inspect import Parameter,Signature from copy import copy prms = [] for v in data.values(): v = copy(v) kind = getattr(Parameter, v.pop('kind')) prms.append(Parameter(kind=kind, **v)) return Signature(prms) # sigfd(data) def signature_from_text(text): loc = {} glob = {} try: exec( compile('from typing import *\ndef function%s:pass' % text, '', 'exec'), glob, loc ) sig = inspect.signature(loc['function']) except Exception as e: print(' failed:>>> def function%s:pass' % text) return inspect.signature(foo) raise return sig #### def parameter_dump(p): """ Given a parameter (from inspect signature), dump to to json """ # TODO: mapping of kind and drop default if inspect empty + annotations. return {'kind': str(p.kind), 'name': p.name, 'default': hexuniformify(str(p.default))} def sig_dump(sig): """ Given a signature (from inspect signature), dump ti to json """ return {k: parameter_dump(v) for k, v in sig.parameters.items()} def fully_qualified(obj: object) -> str: """ (try to) return the fully qualified name of an object """ if obj is types.FunctionType: # noqa return '%s.%s' % (obj.__module__, obj.__qualname__) else: return '%s.%s' % (obj.__class__.__module__, obj.__class__.__name__) class Visitor(BaseVisitor): def visit_metaclass_instance(self, meta_instance): return self.visit_type(meta_instance) pass def visit_unknown(self, unknown): self.rejected.append(unknown) self.logger.debug('Unknown: ========') self.logger.debug('Unknown: No clue what to do with %s', unknown) self.logger.debug('Unknown: isinstance(node, object) %s', isinstance(unknown, object)) self.logger.debug('Unknown: isinstance(node, type) %s', isinstance(unknown, type)) self.logger.debug('Unknown: type(node) %s', type(unknown)) if type(unknown) is type: self.logger.debug('Unknown: issubclass(unknown, type) %s', issubclass(unknown, type)) self.logger.debug( 'Unknown: issubclass(type(unknown), type) %s %s', issubclass(type(unknown), type), type(unknown) ) self.logger.debug('Unknown: type(unknown) is type : %s', type(unknown) is type) self.logger.debug( 'Unknown: hasattr(unknown, "__call__"): %s', hasattr(unknown, "__call__") ) self.logger.debug('Unknown: ========') def visit_method_descriptor(self, meth): pass def visit_builtin_function_or_method(self, b): pass def visit_method(self, b): return self.visit_function(b) def visit_function(self, function): name = function.__module__ #else: # name = '%s.%s' % (function.__class__.__module__, function.__class__.__name__) fullqual = '{}.{}'.format(name, function.__qualname__) #if 'leading_empty_lines' in function.__qualname__: # raise ValueError(fullqual, function.__module__, type(function) ) # try: sig = hexuniformify(str(inspect.signature(function))) # except ValueError: # return self.logger.debug(' visit_function {f}{s}'.format(f=fullqual, s=sig)) self.collected.add(fullqual) self.spec[fullqual] = { 'type': 'function', 'signature': sig_dump(inspect.signature(function)) } self._consistent(fullqual, function) return fullqual def visit_instance(self, instance): self.rejected.append(instance) self.logger.debug(' visit_instance %s', instance) try: return str(instance) except: pass def visit_type(self, type_): local_key = type_.__module__ + '.' + type_.__qualname__ items = {} self.logger.debug('Class %s' % type_.__module__ + '.' + type_.__qualname__) for k in sorted(dir(type_)): if not k.startswith('_'): items[k] = self.visit(getattr(type_, k)) items = {k: v for k, v in items.items() if v} self.spec[local_key] = {'type': 'type', 'items': items} self.collected.add(local_key) return local_key def visit_module(self, module): self.logger.debug('Module %s' % module) if not module.__name__.startswith(self.name): self.logger.debug( 'out of scope %s vs %s : %s' % (module.__name__, self.name, module.__name__.startswith(self.name)) ) return None for k in dir(module): if k.startswith('_') and not (k.startswith('__') and k.endswith('__')): self.logger.debug( ' visit_module: skipping private attribute: %s.%s' % (module.__name__, k) ) continue else: self.logger.debug( ' visit_module: visiting public attribute; %s.%s' % (module.__name__, k) ) self.visit(getattr(module, k)) def param_compare(old, new): if old is None: print(' New paramters', repr(new)) return print(' ', old, '!=', new) def params_compare(old_ps, new_ps): try: from itertools import zip_longest for (o, ov), (n, nv) in zip_longest(old_ps.items(), new_ps.items(), fillvalue=(None, None)): if o == n and ov == nv: continue param_compare(ov, nv) except: import ipdb ipdb.set_trace() def visit_modules(rootname: str, modules): """ visit given modules and return a tree visitor that have visited the given modules. Will only recursively visit modules with fully qualified names starting with `rootname`. It is possible to pass several modules to inspect as python does not always expose submodules, as they need to be explicitly imported. For example, `matplotlib` does not expose `matplotlib.finance`, so a user would need to do `visit_module('matplotlib', [matplotlib, matplotlib.finance]` after explicitly having imported both. Another example would be namespace packages. This is not made to explore at one multiple top level modules. (Maybe we should allow that for things that re-expose other projects but that's a question for another time. """ tree_visitor = Visitor(rootname.split('.')[0], logger=logger) skipped = [] for module_name in modules: # Here we allow also ModuleTypes for easy testing, figure out a clean # way with stable types. Likely move the requirement to import things # one more level up, then we can also remove the need for catching # import,runtime and attribute errors and push it to the caller. if isinstance(module_name, types.ModuleType): module = module_name else: try: module = importlib.import_module(module_name) except (ImportError, RuntimeError, AttributeError) as e: skipped.append(module_name) continue tree_visitor.visit(module) return skipped, tree_visitor def compare(old_spec, new_spec, *, tree_visitor): """ Given an old_specification and a new_specification print differences. Todo: yield better structured informations """ old_keys = set(old_spec.keys()) common_keys = new_spec.intersection(old_keys) removed_keys = old_keys.difference(new_spec) new_keys = new_spec.difference(old_keys) if new_keys: yield ("The following items are new:", ) for k in new_keys: yield ' '+k, yield if removed_keys: yield ( "The following items have been removed, (or moved to super-class)", ) for k in removed_keys: yield (' '+k,) yield # Todo, print that only if there are differences. yield ("The following signature differ between versions:", ) for key in common_keys: # if isinstance(old_spec[key], str): # from_dump = hexuniformify(old_spec[key]) # current_spec = hexuniformify(tree_visitor.spec[key]) # else: from_dump = old_spec[key] current_spec = tree_visitor.spec[key] if from_dump != current_spec: if current_spec['type'] == 'type': # Classes / Module / Function current_spec = current_spec['items'] from_dump = from_dump['items'] removed = [k for k in from_dump if k not in current_spec] if not removed: continue yield yield (" %s" % (key), ) new = [k for k in current_spec if k not in from_dump] if new: for n in new: yield (' new:', n) removed = [k for k in from_dump if k not in current_spec] if removed: for r in removed: yield (' removed:', r) elif current_spec['type'] == 'function': from_dump = from_dump['signature'] current_spec = current_spec['signature'] yield yield (" %s" % (key), ) yield (" - %s%s" % (key, sigfd(from_dump)), ) yield (" + %s%s" % (key, sigfd(current_spec)), ) #params_compare(from_dump, current_spec) else: yield ('unknown node:', current_spec) def main(): import argparse from textwrap import dedent from argparse import RawTextHelpFormatter parser = argparse.ArgumentParser( description=dedent(""" An easy way to be confident you haven't broken API contract since a previous version, or see what changes have been made."""), formatter_class=RawTextHelpFormatter, epilog=dedent(""" Example: $ pip install 'ipython==5.1.0' $ frappuccino IPython --save IPython-5.1.0.json $ pip install 'ipython==6.0.0' $ frappuccino IPython --compare IPython-5.1.0.json ... list of API changes found + non zero exit code if incompatible ... """), allow_abbrev=False ) parser.add_argument( 'modules', metavar='modules', type=str, nargs='+', help='root modules and submodules' ) parser.add_argument('--save', action='store', help='file to dump API to', metavar='') parser.add_argument( '--compare', action='store', help='file with dump API to compare to', metavar='' ) parser.add_argument('--debug', action='store_true') # TODO add stdin/stdout options for spec. options = parser.parse_args() if options.save and options.compare: parser.print_help() sys.exit('options `--save` and `--compare` are exclusive') if options.debug: logger.setLevel('DEBUG') rootname = options.modules[0] tree_visitor = Visitor(rootname.split('.')[0], logger=logger) skipped, tree_visitor = visit_modules(rootname, options.modules) if skipped: print('skipped modules :', ','.join(skipped)) print( "Collected:", len(tree_visitor.collected), "Visited:", len(tree_visitor.visited), "Rejected:", len(tree_visitor.rejected) ) if options.save: with open(options.save, 'w') as f: f.write(json.dumps(tree_visitor.spec, indent=2)) if options.compare: with open(options.compare, 'r') as f: loaded = json.loads(f.read()) skeys = set(tree_visitor.spec.keys()) for c in compare(loaded, skeys, tree_visitor=tree_visitor): if c is None: print() else: print(*c) if __name__ == '__main__': main() PKIDJo$$frappuccino/__main__.pyfrom frappuccino import main main() PKB&JNLLfrappuccino/astinit.py"""Frappucinno DEPRECATED/UNUSED This was a proof of concept trying to visit the AST, though Python is dynamic enough that this is often too limited. in the end I choose to go with actually importing things, and walking every reachable object which fully qualified name does start with give prefixes. Freeze your API and make sure you do not introduce backward incompatibilities """ import ast test1 = """ class Bird: def bar(a,b, *args, kow, **kw ): pass def foo(c): pass def missing(): pass def _private(): pass """ test2 = """ class Bird: def bar(a,b, *args, kow, **kw ): pass def foo(c): pass def _private(): pass """ def keyfy(s): return '"%s":' % s class APIVisitor: """ A node visitor base class that walks the abstract syntax tree and calls a visitor function for every node found. This function may return a value which is forwarded by the `visit` method. This class is meant to be subclassed, with the subclass adding visitor methods. Per default the visitor functions for the nodes are ``'visit_'`` + class name of the node. So a `TryFinally` node visit function would be `visit_TryFinally`. This behavior can be changed by overriding the `visit` method. If no visitor function exists for a node (return value `None`) the `generic_visit` visitor is used instead. Don't use the `NodeVisitor` if you want to apply changes to nodes during traversing. For this a special visitor exists (`NodeTransformer`) that allows modifications. """ def visit(self, node): """Visit a node.""" method = 'visit_' + node.__class__.__name__ visitor = getattr(self, method, self.generic_visit) return visitor(node) def generic_visit(self, node): """Called if no explicit visitor function exists for a node.""" res = [] for field, value in ast.iter_fields(node): if isinstance(value, list): for item in value: vv = self.visit(item) res.append(vv) elif isinstance(value, dict): for k, v in value: res.append(self.visit(v)) res.append(self.visit(value)) if not res: print("visiting", node) return list(filter(None, res)) def visit_FunctionDef(self, node): if node.name.startswith('_'): return None args = node.args return { node.name: { 'type': node.__class__.__name__, 'args': [a.arg for a in args.args], 'kwonlyargs': [a.arg for a in args.kwonlyargs], 'vararg': args.vararg.arg if args.vararg else [], 'kwarg': args.kwarg.arg if args.kwarg else [], } } def visit_ClassDef(self, node): vis = self.generic_visit(node) d = {} for item in vis: d.update(item) return {node.name: {'type': node.__class__.__name__, 'attributes': d}} def is_compatible(old_tree, new_tree): pass class DoubleTreeVisitor: """ Like AstVisitor, but compare two tree for compatibility. """ def visit(self, old_list, new_list, name=None): """Visit a node.""" for old_node, new_node in zip(old_list, new_list): for k, v in old_node.items(): if k in new_node: method = 'visit_' + v['type'] visitor = getattr(self, method, self.generic_visit) visitor(v, new_node[k], k) def generic_visit(self, old_node, new_node): """Called if no explicit visitor function exists for a node.""" res = [] for field, value in old_node.items(): if isinstance(value, list): for item in value: if isinstance(item, ast.AST): res.append(self.visit(item)) elif isinstance(value, ast.AST): res.append(self.visit(value)) return list(filter(None, res)) def visit_ClassDef(self, old_class, new_class, name): missing_attributes = set(old_class['attributes'].keys() ).difference(set(new_class['attributes'].keys())) if missing_attributes: print( 'Class `{}` has lost non deprecated and non private following attributes : {}'. format(name, *missing_attributes) ) self.generic_visit(old_class, new_class) if __name__ == '__main__': tree = ast.parse(test1) serialized_tree = APIVisitor().visit(tree) # pprint(serialized_tree) tree2 = ast.parse(test2) serialized_tree2 = APIVisitor().visit(tree2) dt = DoubleTreeVisitor().visit(serialized_tree, serialized_tree2) PK$JwA JJfrappuccino/logging.pyimport logging logging.basicConfig() logger = logging.getLogger(__name__) PKB&Jfrappuccino/visitor.py""" """ from types import ModuleType from .logging import logger as _logger class BaseVisitor: """ Visitor base class to recursively walk a give module and all its descendant. The generic `visit` method does return a predictable immutable hashable key for the given node in order to avoid potential cycles, and to re-compute information about a given node. Subclass should define multiple methods named `visit_(self, object)`, that should return predictable and stable keys for passed object. The generic `visit` method will dispatch on the given `visit_*` method when it visit a given type, and will fallback on `visit_unknown(self, obj)` if no corresponding method is found. TODO: figure out and document when to add stuff to rejected, collected, and visited, as well as the exact meaning. Consider having a `(rejected, reason)` tuple. I can already see a couple of reasons: 1) out of scope (import from another library, which is still exposed) 2) Private field 3) Black/whitelisted while in dev. """ def __init__(self, name: str, *, logger=None): """ Parameters ========== name: str Base name of a module to inspect. All found module which fully qualified name do not start with this will not be recursed into. logger: Logger Logger instance to use to print debug messages. """ self.name = name # list of visited nodes to avoid recursion and going in circle. # can't be a set we store non-hashable objects # which is weird why not store memory-location -> object ? # anyway... self.visited = list() self._hash_cache = dict() # set of object keys that where deemed worth collecting self.collected = set({}) # list of object we did not visit (for example, we encounter an object # not from targeted module, from the stdlib.... self.rejected = list() # dict of key -> custom spec that should be serialised for later # comparison later. self.spec = dict() # debug, make sure 2 objects are not getting the same key self._consistency = {} if not logger: self.logger = _logger else: self.logger = logger def _consistent(self, key, value): """ If the current object we are visiting map to the same key and the same value. As we do some normalisation (like for closure that have a `` name, we may end up with things conflicting. This is more prevention in case on one project at some point we get a collision then we can debug that. """ if key in self._consistency: if self._consistency[key] is not value: self.logger.info( "Warning %s is not %s, results may not be consistent" % (self._consistency[key], value) ) else: self._consistency[key] = value def visit(self, node): """ Visit current node and return its identification key if visitable. If node is not visitable, return `None`. """ try: if id(node) in [id(x) for x in self.visited]: # todo, if visited check the localkey and return it. # otherwise methods moved to superclass will/may be lost. # or not correctly reported return self._hash_cache.get(id(node)) else: # that seem to be wrong, we likely should put id(node) in that. self.visited.append(node) except TypeError: # non equalable things (eg dtype/modules) return None mod = getattr(node, '__module__', None) if mod and not mod.startswith(self.name): self.rejected.append(node) return if isinstance(node, ModuleType): type_ = 'module' elif isinstance(node, object ) and not isinstance(node, type) and not hasattr(node, '__call__'): type_ = 'instance' elif issubclass(type(node), type) and type(node) is not type: type_ = 'metaclass_instance' else: type_ = type(node).__name__ visitor = getattr(self, 'visit_' + type_, self.visit_unknown) visited_hash = visitor(node) self._hash_cache[id(node)] = visited_hash return visited_hash PKVJfrappuccino/tests/__init__.pyPK$J' IIfrappuccino/tests/new.pydef unchanged(a, b=1, *, c=2): pass def changed(x, b, c): pass PK$JoIIfrappuccino/tests/old.pydef unchanged(a, b=1, *, c=2): pass def changed(a, b, c): pass PKX&J)bhhfrappuccino/tests/test_all.pyfrom . import old from . import new import json from .. import main, visit_modules, compare def test_old_new(): skipped_o, old_spec = visit_modules('', [old]) skipped_n, new_spec = visit_modules('', [new]) assert skipped_o == [] assert skipped_n == [] skeys = set(new_spec.spec.keys()) l = list(compare(old_spec.spec, skeys, tree_visitor=new_spec)) assert json.dumps(old_spec.spec) is not '{}' assert l == [ ('The following signature differ between versions:', ), None, ('function> builtins.function.changed', ), (' builtins.function.changed', ) ] PK!HA()0,frappuccino-0.0.2.dist-info/entry_points.txtN+I/N.,()J+J,((MN˷Eb[&fqqPKCJA3#frappuccino-0.0.2.dist-info/LICENSEBSD 3-Clause License Copyright (c) 2017, Matthias Bussonnier All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. PK!HPO!frappuccino-0.0.2.dist-info/WHEEL HM K-*ϳR03rOK-J,/RH,szd&Y)r$[)T&UrPK!H ?g $frappuccino-0.0.2.dist-info/METADATAMJ@)-,(hS1h48+@,3[JxĄ7Id jВ`yu3=sfkQ.^n2o uH)DS=!tmF\߶sG.WZ4x1ŔwI&VE>8\OH-I?bq)Na18i0v+,CK^XPK!H&a/h"frappuccino-0.0.2.dist-info/RECORD}ҷP~\2XHAGw gtƱREM310pܑ4/8I"[eL 5U%mcN_ x`NI.䮇Y(D{7}]L2PɼhQ?|uŗٽ-%Mg"Q&8ZS WyY~ 85Me}`M6i-4%Tm`",MC=aq9%ۏ6{jF0?!#/ܐC+pU{tS[LwC7dOYXx| tf;.冸En!ݙ",;ceJ3o6i4t_ |2f?8r.t:@'2Kͣuy[a+b~@ai5ta56<2x>Pf?Y KK.\/: /-f}0Tlw!˧ܱPUAWo•tp9&p \#8?{!k #gS8N#13b( OӠ.R]vM-f,W-jJ T]j2~rm(G&nBpr$PKzNNs7777frappuccino/__init__.pyPKIDJo$$l7frappuccino/__main__.pyPKB&JNLL7frappuccino/astinit.pyPK$JwA JJEKfrappuccino/logging.pyPKB&JKfrappuccino/visitor.pyPKVJ]frappuccino/tests/__init__.pyPK$J' II^frappuccino/tests/new.pyPK$JoII^frappuccino/tests/old.pyPKX&J)bhh_frappuccino/tests/test_all.pyPK!HA()0,afrappuccino-0.0.2.dist-info/entry_points.txtPKCJA3#,bfrappuccino-0.0.2.dist-info/LICENSEPK!HPO!\hfrappuccino-0.0.2.dist-info/WHEELPK!H ?g $hfrappuccino-0.0.2.dist-info/METADATAPK!H&a/h"ifrappuccino-0.0.2.dist-info/RECORDPKl