From 8db39f815dccb051cd2588fc6db79758d65513f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9rome=20Perrin?= <jerome@nexedi.com> Date: Tue, 12 Nov 2019 10:55:59 +0000 Subject: [PATCH] jedi wip --- .../portal_components/extension.erp5.Jedi.py | 190 ++++++++---------- .../portal_components/extension.erp5.Jedi.xml | 9 +- .../extension.erp5.PythonCodeUtils.py | 87 +++++++- 3 files changed, 175 insertions(+), 111 deletions(-) diff --git a/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.py b/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.py index 9917ecf8e0..ae77c8447e 100644 --- a/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.py +++ b/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.py @@ -1,8 +1,9 @@ from __future__ import unicode_literals import json import sys -from threading import RLock +import typing import logging +from threading import RLock logger = logging.getLogger("erp5.extension.Jedi") @@ -16,45 +17,40 @@ last_reload_time = time.time() # XXX I'm really not sure this is needed, jedi seems fast enough. jedi.settings.call_signatures_validity = 30 -# monkey patch to disable buggy sys.path addition based on buildout. -# rdiff-backup seem to trigger a bug, but it's generally super slow and not correct for us. -try: - # in jedi 0.15.1 it's here - from jedi.evaluate import sys_path as jedi_inference_sys_path # pylint: disable=import-error,unused-import,no-name-in-module -except ImportError: - # but it's beeing moved. Next release will be here - # https://github.com/davidhalter/jedi/commit/3b4f2924648eafb9660caac9030b20beb50a83bb - from jedi.inference import sys_path as jedi_inference_sys_path # pylint: disable=import-error,unused-import,no-name-in-module -_ = jedi_inference_sys_path.discover_buildout_paths # make sure it's here - +if True: + # monkey patch to disable buggy sys.path addition based on buildout. + # https://github.com/davidhalter/jedi/issues/1325 + # rdiff-backup seem to trigger a bug, but it's generally super slow and not correct for us. + try: + # in jedi 0.15.1 it's here + from jedi.evaluate import sys_path as jedi_inference_sys_path # pylint: disable=import-error,unused-import,no-name-in-module + except ImportError: + # but it's beeing moved. Next release (0.15.2) will be here + # https://github.com/davidhalter/jedi/commit/3b4f2924648eafb9660caac9030b20beb50a83bb + from jedi.inference import sys_path as jedi_inference_sys_path # pylint: disable=import-error,unused-import,no-name-in-module + _ = jedi_inference_sys_path.discover_buildout_paths # make sure it's here -def dont_discover_buildout_paths(*args, **kw): - return set() + def dont_discover_buildout_paths(*args, **kw): + return set() + jedi_inference_sys_path.discover_buildout_paths = dont_discover_buildout_paths + from jedi.api import project as jedi_api_project + jedi_api_project.discover_buildout_paths = dont_discover_buildout_paths -jedi_inference_sys_path.discover_buildout_paths = dont_discover_buildout_paths -from jedi.api import project as jedi_api_project -jedi_api_project.discover_buildout_paths = dont_discover_buildout_paths +from jedi.evaluate.context.instance import TreeInstance +from jedi.evaluate.gradual.typing import InstanceWrapper +from jedi.evaluate.lazy_context import LazyKnownContexts +from jedi.evaluate.base_context import ContextSet, NO_CONTEXTS -try: - # for local types annotations in this component - from erp5.portal_type import ERP5Site # pylint: disable=unused-import,no-name-in-module -except ImportError: - pass +if typing.TYPE_CHECKING: + import erp5.portal_type.ERP5Site # pylint: disable=unused-import,no-name-in-module,import-error def executeJediXXX(callback, context, arguments): - from jedi.evaluate.base_context import ContextSet - # XXX function for relaodability def call(): return callback(context, arguments=arguments) - from jedi.evaluate.context.instance import TreeInstance - from jedi.evaluate.gradual.typing import InstanceWrapper - from jedi.evaluate.lazy_context import LazyKnownContexts - from jedi.evaluate.base_context import ContextSet, NO_CONTEXTS - def makeFilterFunc(class_from_portal_type, arguments): def filter_func(val): if isinstance(val, TreeInstance) and val.tree_node.type == 'classdef': @@ -71,6 +67,7 @@ def executeJediXXX(callback, context, arguments): for wrapped in val.iterate(): if filter_func(wrapped): return True + return False annotation_classes = val.gather_annotation_classes() #import pdb; pdb.set_trace() return val.gather_annotation_classes().filter(filter_func) @@ -87,6 +84,9 @@ def executeJediXXX(callback, context, arguments): if not arguments.argument_node: return call() # no portal_type, we'll use what's defined in the stub + original = call() + #logger.info('re-evaluating %s ...', original) + # look for a "portal_type=" argument for arg_name, arg_value in arguments.unpack(): if arg_name == 'portal_type': @@ -120,15 +120,11 @@ def executeJediXXX(callback, context, arguments): def makeERP5Plugin(): logger.info('making erp5 plugin') - from jedi.evaluate.base_context import ContextSet - from jedi.parser_utils import get_cached_code_lines - from StringIO import StringIO - class JediERP5Plugin(object): _cache = {} def _getPortalObject(self): # XXX needed ? - # type: () -> ERP5Site + # type: () -> erp5.portal_type.ERP5Site from Products.ERP5.ERP5Site import getSite from Products.ERP5Type.Globals import get_request from ZPublisher.BaseRequest import RequestContainer @@ -142,53 +138,10 @@ def makeERP5Plugin(): logger.info("JediERP5Plugin registering execute") def wrapper(context, arguments): + # XXX call an external function that will be reloaded from erp5.component.extension.Jedi import executeJediXXX as _execute return _execute(callback, context, arguments) - def call(): - return callback(context, arguments=arguments) - - # methods returning portal types - if context.is_function(): - # and 1 or context.get_function_execution( - #).function_context.name.string_name == 'newContent': - if not arguments.argument_node: - return call( - ) # no portal_type, we'll use what's defined in the stub - - # look for a "portal_type=" argument - for arg_name, arg_value in arguments.unpack(): - if arg_name == 'portal_type': - try: - portal_type = iter(arg_value.infer()).next().get_safe_value() - except Exception: - logger.exception("error infering") - continue - if not isinstance(portal_type, str): - continue - logger.info( - 'ahah portal_type based method with portal type=%s ...', - portal_type) - # XXX this is really horrible - class_from_portal_type = portal_type.replace(' ', '') + '@' - original = call() - if 0: - filtered = ContextSet.from_sets({s for s in original}) - import pdb - pdb.set_trace() - original._set = frozenset({ - x for x in original._set if class_from_portal_type in str(x) - }) - logger.info( - 'portal_type based method, returning\n %s instead of\n %s', - original, - call()) - #import pdb; pdb.set_trace() - return original - - # methods returning List of portal types - # methods returning List of Brain of portal types - return call() return wrapper return JediERP5Plugin() @@ -236,12 +189,20 @@ _TYPE_MAP = { def _label(definition): + # type: (jedi.api.classes.Completion,) -> str + if definition.type == 'param': + return '{}='.format(definition.name) if definition.type in ('function', 'method') and hasattr(definition, 'params'): params = ', '.join([param.name for param in definition.params]) return '{}({})'.format(definition.name, params) return definition.name +def _insertText(definition): + # type: (jedi.api.classes.Completion,) -> str + if definition.type == 'param': + return '{}='.format(definition.name) + return definition.name def _detail(definition): try: @@ -264,21 +225,22 @@ def _format_docstring(docstring): def _format_completion(d): + # type: (jedi.api.classes.Completion,) -> typing.Dict[str, str] completion = { 'label': _label(d), '_kind': _TYPE_MAP.get(d.type), 'detail': _detail(d), 'documentation': _format_docstring(d.docstring()), 'sortText': _sort_text(d), - 'insertText': d.name + 'insertText': _insertText(d), } return completion def _guessType(name, context_type=None): """guess the type of python script parameters based on naming conventions. - """ + # TODO: `state_change` arguments for workflow scripts name = name.split('=')[ 0] # support also assigned names ( like REQUEST=None in params) if name == 'context' and context_type: @@ -286,7 +248,7 @@ def _guessType(name, context_type=None): if name in ( 'context', 'container',): - return 'ERP5Site' + return 'erp5.portal_type.ERP5Site' if name == 'script': return 'Products.PythonScripts.PythonScript' if name == 'REQUEST': @@ -298,7 +260,7 @@ def _guessType(name, context_type=None): # Jedi is not thread safe import Products.ERP5Type.Utils -jedi_lock = getattr(Products.ERP5Type.Utils, 'jedi_lock', None) +jedi_lock = getattr(Products.ERP5Type.Utils, 'jedi_lock', None) # type: RLock if jedi_lock is None: logger.critical("There was no lock, making a new one") jedi_lock = Products.ERP5Type.Utils.jedi_lock = RLock() @@ -310,6 +272,8 @@ def ERP5Site_getPythonSourceCodeCompletionList(self, data, REQUEST=None): """ portal = self.getPortalObject() logger.debug('jedi get lock %s (%s)', jedi_lock, id(jedi_lock)) + if not jedi_lock.acquire(False): + raise RuntimeError('jedi is locked') with jedi_lock: # register our erp5 plugin @@ -323,6 +287,7 @@ def ERP5Site_getPythonSourceCodeCompletionList(self, data, REQUEST=None): # data contains the code, the bound names and the script params. From this # we reconstruct a function that can be checked + def indent(text): return ''.join((" " + line) for line in text.splitlines(True)) @@ -347,9 +312,10 @@ def ERP5Site_getPythonSourceCodeCompletionList(self, data, REQUEST=None): "context_type %s has no portal type, using ERP5Site", context_type) context_type = None + else: + context_type = 'erp5.portal_type.{}'.format(context_type) - imports = "from erp5.portal_type import {}; import Products.ERP5Type.Core.Folder; import ZPublisher.HTTPRequest; import Products.PythonScripts".format( - context_type or 'ERP5Site') + imports = "import erp5.portal_type; import Products.ERP5Type.Core.Folder; import ZPublisher.HTTPRequest; import Products.PythonScripts" type_annotation = " # type: ({}) -> None".format( ', '.join( [_guessType(part, context_type) for part in signature_parts])) @@ -771,7 +737,6 @@ def TypeInformation_getStub(self): # everything can use ERP5Site_ skins imports.add('from erp5.skins_tool import ERP5Site as skins_tool_ERP5Site') base_classes.append('skins_tool_ERP5Site') - base_classes.append(prefixed_class_name) class_template = textwrap.dedent( @@ -865,7 +830,7 @@ def PropertySheet_getStub(self): docstring=safe_docstring("ahaha cool :)"))) def _getPythonTypeFromPropertySheetType(prop): - # type: (erp5.portal_type.StandardProperty) -> str + # type: (erp5.portal_type.StandardProperty,) -> str property_sheet_type = prop.getElementaryType() if property_sheet_type in ('content', 'object'): # TODO @@ -883,10 +848,20 @@ def PropertySheet_getStub(self): 'float': 'float', 'text': 'str', }.get(property_sheet_type, 'Any') - if prop.isMultivalued(): + if prop.isMultivalued() \ + and property_sheet_type not in ('lines', 'token'): + # XXX see Resource/p_variation_base_category_property, we can have multivalued lines properties return 'List[{}]'.format(mapped_type) return mapped_type + def _isMultiValuedProperty(prop): + # type: (erp5.portal_type.StandardProperty,) -> str + """If this is a multi valued property, we have to generate list accessor. + """ + if prop.isMultivalued(): + return True + return prop.getElementaryType() in ('lines', 'tokens') + from Products.ERP5Type.Utils import convertToUpperCase from Products.ERP5Type.Utils import evaluateExpressionFromString from Products.ERP5Type.Utils import createExpressionContext @@ -908,8 +883,10 @@ def PropertySheet_getStub(self): property_url=prop.absolute_url())) methods.append( method_template_template.format( - method_name='get{}'.format( - convertToUpperCase(prop.getReference())), + method_name='get{}{}'.format( + convertToUpperCase(prop.getReference()), + 'List' if _isMultiValuedProperty(prop) else '', + ), method_args='self', return_type=_getPythonTypeFromPropertySheetType(prop), docstring=docstring)) @@ -923,8 +900,10 @@ def PropertySheet_getStub(self): docstring=docstring)) methods.append( method_template_template.format( - method_name='set{}'.format( - convertToUpperCase(prop.getReference())), + method_name='set{}{}'.format( + convertToUpperCase(prop.getReference()), + 'List' if _isMultiValuedProperty(prop) else '', + ), method_args='self, value:{}'.format( _getPythonTypeFromPropertySheetType(prop)), return_type='None', @@ -1029,11 +1008,8 @@ def PropertySheet_getStub(self): ) -from Products.ERP5.ERP5Site import ERP5Site # pylint: disable=unused-import - - def ERP5Site_getPortalStub(self): - # type: (ERP5Site) -> str + # type: (erp5.portal_type.ERP5Site,) -> erp5.portal_type.ERP5Site module_stub_template = textwrap.dedent( ''' @@ -1078,9 +1054,10 @@ def ERP5Site_getPortalStub(self): return textwrap.dedent( ''' - from Products.ERP5Site.ERP5Site import ERP5Site as ERP5Site_parent_ERP5Site + from Products.ERP5.ERP5Site import ERP5Site as ERP5Site_parent_ERP5Site from erp5.skins_tool import ERP5Site as skins_tool_ERP5Site from erp5.skins_tool import Base as skins_tool_Base + class ERP5Site(ERP5Site_parent_ERP5Site, skins_tool_ERP5Site, skins_tool_Base): {} def getPortalObject(self): @@ -1089,6 +1066,7 @@ def ERP5Site_getPortalStub(self): def ERP5Site_dumpModuleCode(self, component_or_script=None): + # type: (erp5.portal_type.ERP5Site,) -> None """Save code in filesystem for jedi to use it. Generate stubs for erp5.* dynamic modules and copy the in-ZODB modules @@ -1098,10 +1076,7 @@ def ERP5Site_dumpModuleCode(self, component_or_script=None): if not os.path.exists(path): os.mkdir(path, 0o700) - # this import is for type annotation, but pylint does not understand this. - import erp5.portal_type # pylint: disable=unused-variable - - portal = self.getPortalObject() # type: erp5.portal_type.ERP5Site + portal = self.getPortalObject() module_dir = '/tmp/ahaha/erp5/' # TODO mkdir_p(module_dir) @@ -1139,8 +1114,19 @@ def ERP5Site_dumpModuleCode(self, component_or_script=None): ), 'w', ) as type_information_f: - type_information_f.write( - ti.TypeInformation_getStub().encode('utf-8')) + try: + stub_code = ti.TypeInformation_getStub().encode('utf-8') + except Exception as e: + logger.exception("Could not generate code for %s", ti.getId()) + stub_code = """class {class_name}:\n {error}""".format( + class_name=class_name, + error=safe_docstring("Error trying to create {}: {} {}".format( + ti.getId(), + e.__class__, + e + )) + ) + type_information_f.write(stub_code) # portal type groups ( useful ? used in Simulation Tool only ) portal_types_by_group = defaultdict(list) diff --git a/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.xml b/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.xml index 871f59fa4b..6a4f86cf70 100644 --- a/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.xml +++ b/bt5/erp5_monaco_editor/ExtensionTemplateItem/portal_components/extension.erp5.Jedi.xml @@ -45,14 +45,7 @@ <item> <key> <string>text_content_warning_message</string> </key> <value> - <tuple> - <string>W: 61, 2: Reimport \'ContextSet\' (imported line 52) (reimported)</string> - <string>W: 79, 8: Unused variable \'annotation_classes\' (unused-variable)</string> - <string>W:156, 8: Unreachable code (unreachable)</string> - <string>W:184, 16: Unused variable \'filtered\' (unused-variable)</string> - <string>W:132, 2: Unused variable \'get_cached_code_lines\' (unused-variable)</string> - <string>W:133, 2: Unused variable \'StringIO\' (unused-variable)</string> - </tuple> + <tuple/> </value> </item> <item> diff --git a/product/ERP5/bootstrap/erp5_core/ExtensionTemplateItem/portal_components/extension.erp5.PythonCodeUtils.py b/product/ERP5/bootstrap/erp5_core/ExtensionTemplateItem/portal_components/extension.erp5.PythonCodeUtils.py index 86b1d51b62..648e6704d3 100644 --- a/product/ERP5/bootstrap/erp5_core/ExtensionTemplateItem/portal_components/extension.erp5.PythonCodeUtils.py +++ b/product/ERP5/bootstrap/erp5_core/ExtensionTemplateItem/portal_components/extension.erp5.PythonCodeUtils.py @@ -1,10 +1,16 @@ +import time import json +import logging from Products.ERP5Type.Utils import checkPythonSourceCode +logger = logging.getLogger('extension.erp5.PythonCodeUtils') + + def checkPythonSourceCodeAsJSON(self, data, REQUEST=None): """ Check Python source suitable for Source Code Editor and return a JSON object """ + import json # XXX data is encoded as json, because jQuery serialize lists as [] if isinstance(data, basestring): @@ -16,6 +22,7 @@ def checkPythonSourceCodeAsJSON(self, data, REQUEST=None): return ''.join((" " + line) for line in text.splitlines(True)) # don't show 'undefined-variable' errors for {Python,Workflow} Script parameters + script_name = data.get('script_name') or 'unknown.py' is_script = 'bound_names' in data if is_script: signature_parts = data['bound_names'] @@ -30,7 +37,85 @@ def checkPythonSourceCodeAsJSON(self, data, REQUEST=None): else: body = data['code'] - message_list = checkPythonSourceCode(body.encode('utf8'), data.get('portal_type')) + start = time.time() + code = body.encode('utf8') + + import pyflakes.api + import pyflakes.reporter + import pyflakes.messages + + class Reporter(pyflakes.reporter.Reporter): + def __init__(self): # pylint: disable=super-init-not-called + self.message_list = [] + + def addMessage(self, row, column, level, text): + self.message_list.append( + dict(row=row, column=column, type=level, text=text)) + + def flake(self, message): + # type: (pyflakes.messages.Message,) -> None + self.addMessage( + row=message.lineno, + column=message.col, + text=message.message % (message.message_args), + level='W') + + def syntaxError(self, filename, msg, lineno, offset, text): + self.addMessage( + row=lineno, + column=offset, + text='SyntaxError: {}'.format(text), + level='E') + + def unexpectedError(self, filename, msg): + # TODO: extend interface to have range and in this case whole range is wrong ? + # or use parse with python in that case ? + # repro: function(a="b", c) + self.addMessage( + row=0, column=0, text='Unexpected Error: {}'.format(msg), level='E') + + start = time.time() + reporter = Reporter() + pyflakes.api.check(code, script_name, reporter) + logger.info( + 'pyflake checked %d lines in %.2f', + len(code.splitlines()), + time.time() - start + ) + + message_list = reporter.message_list + + import lib2to3.refactor + import lib2to3.pgen2.parse + refactoring_tool = lib2to3.refactor.RefactoringTool(fixer_names=('lib2to3.fixes.fix_except', )) + old_code = code.decode('utf-8') + try: + new_code = unicode(refactoring_tool.refactor_string(old_code, script_name)) + except lib2to3.pgen2.parse.ParseError as e: + message, (row, column) = e.context + message_list.append( + dict(row=row, column=column, type='E', text=message)) + else: + if new_code != old_code: + i = 0 + for new_line, old_line in zip(new_code.splitlines(), old_code.splitlines()): + i += 1 + print ('new_line', new_line, 'old_line', old_line) + if new_line != old_line: + message_list.append( + dict(row=i, column=0, type='W', text=u'-{}\n+{}'.format(old_line, new_line))) + +# import pdb; pdb.set_trace() + pylint_message_list = [] + if 0: + start = time.time() + pylint_message_list = checkPythonSourceCode(code, data.get('portal_type')) + logger.info( + 'pylint checked %d lines in %.2f', + len(code.splitlines()), + time.time() - start + ) + for message_dict in message_list: if is_script: message_dict['row'] = message_dict['row'] - 2 -- 2.30.9