diff --git a/etg/_core.py b/etg/_core.py index 04d47438c..3addfb5f7 100644 --- a/etg/_core.py +++ b/etg/_core.py @@ -291,7 +291,16 @@ def run(): """) - module.addPyFunction('CallAfter', '(callableObj, *args, **kw)', doc="""\ + module.addPyCode('import typing', order=10) + module.addPyCode("""\ + _T = typing.TypeVar('_T') + try: + _P = typing.ParamSpec('_P') + except AttributeError: + import typing_extensions + _P = typing_extensions.ParamSpec('_P') + """) + module.addPyFunction('CallAfter', '(callableObj: typing.Callable[_P, _T], *args: _P.args, **kw: _P.kwargs) -> None', doc="""\ Call the specified function after the current and pending event handlers have been completed. This is also good for making GUI method calls from non-GUI threads. Any extra positional or @@ -322,7 +331,7 @@ def run(): wx.PostEvent(app, evt)""") - module.addPyClass('CallLater', ['object'], + module.addPyClass('CallLater', ['typing.Generic[_P, _T]'], doc="""\ A convenience class for :class:`wx.Timer`, that calls the given callable object once after the given amount of milliseconds, passing any @@ -342,7 +351,7 @@ def run(): """, items = [ PyCodeDef('__instances = {}'), - PyFunctionDef('__init__', '(self, millis, callableObj, *args, **kwargs)', + PyFunctionDef('__init__', '(self, millis, callableObj: typing.Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ Constructs a new :class:`wx.CallLater` object. @@ -366,7 +375,7 @@ def run(): PyFunctionDef('__del__', '(self)', 'self.Stop()'), - PyFunctionDef('Start', '(self, millis=None, *args, **kwargs)', + PyFunctionDef('Start', '(self, millis: typing.Optional[int]=None, *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ (Re)start the timer @@ -388,7 +397,7 @@ def run(): self.running = True"""), PyCodeDef('Restart = Start'), - PyFunctionDef('Stop', '(self)', + PyFunctionDef('Stop', '(self) -> None', doc="Stop and destroy the timer.", body="""\ if self in CallLater.__instances: @@ -397,16 +406,16 @@ def run(): self.timer.Stop() self.timer = None"""), - PyFunctionDef('GetInterval', '(self)', """\ + PyFunctionDef('GetInterval', '(self) -> int', """\ if self.timer is not None: return self.timer.GetInterval() else: return 0"""), - PyFunctionDef('IsRunning', '(self)', + PyFunctionDef('IsRunning', '(self) -> bool', """return self.timer is not None and self.timer.IsRunning()"""), - PyFunctionDef('SetArgs', '(self, *args, **kwargs)', + PyFunctionDef('SetArgs', '(self, *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ (Re)set the args passed to the callable object. This is useful in conjunction with :meth:`Start` if @@ -421,7 +430,7 @@ def run(): self.args = args self.kwargs = kwargs"""), - PyFunctionDef('HasRun', '(self)', 'return self.hasRun', + PyFunctionDef('HasRun', '(self) -> bool', 'return self.hasRun', doc="""\ Returns whether or not the callable has run. @@ -429,7 +438,7 @@ def run(): """), - PyFunctionDef('GetResult', '(self)', 'return self.result', + PyFunctionDef('GetResult', '(self) -> _T', 'return self.result', doc="""\ Returns the value of the callable. @@ -437,7 +446,7 @@ def run(): :return: result from callable """), - PyFunctionDef('Notify', '(self)', + PyFunctionDef('Notify', '(self) -> None', doc="The timer has expired so call the callable.", body="""\ if self.callable and getattr(self.callable, 'im_self', True): @@ -456,7 +465,7 @@ def run(): module.addPyCode("FutureCall = deprecated(CallLater, 'Use CallLater instead.')") module.addPyCode("""\ - def GetDefaultPyEncoding(): + def GetDefaultPyEncoding() -> str: return "utf-8" GetDefaultPyEncoding = deprecated(GetDefaultPyEncoding, msg="wxPython now always uses utf-8") """) diff --git a/etgtools/extractors.py b/etgtools/extractors.py index 8c992cb14..071cf4841 100644 --- a/etgtools/extractors.py +++ b/etgtools/extractors.py @@ -462,21 +462,11 @@ def makePyArgsString(self): """ Create a pythonized version of the argsString in function and method items that can be used as part of the docstring. - - TODO: Maybe (optionally) use this syntax to document arg types? - http://www.python.org/dev/peps/pep-3107/ """ - def _cleanName(name): - for txt in ['const', '*', '&', ' ']: - name = name.replace(txt, '') - name = name.replace('::', '.') - name = self.fixWxPrefix(name, True) - return name - params = list() returns = list() if self.type and self.type != 'void': - returns.append(_cleanName(self.type)) + returns.append(self.cleanType(self.type)) defValueMap = { 'true': 'True', 'false': 'False', @@ -484,6 +474,7 @@ def _cleanName(name): 'wxString()': '""', 'wxArrayString()' : '[]', 'wxArrayInt()' : '[]', + 'wxEmptyString': "''", # Makes signatures much shorter } if isinstance(self, CppMethodDef): # rip apart the argsString instead of using the (empty) list of parameters @@ -502,7 +493,14 @@ def _cleanName(name): else: default = self.fixWxPrefix(default, True) # now grab just the last word, it should be the variable name - arg = arg.split()[-1] + # The rest will be the type information + arg_type, arg = arg.rsplit(None, 1) + arg, arg_type = self.parseNameAndType(arg, arg_type) + if arg_type: + if default == 'None': + arg = f'{arg}: Optional[{arg_type}]' + else: + arg = f'{arg}: {arg_type}' if default: arg += '=' + default params.append(arg) @@ -513,25 +511,36 @@ def _cleanName(name): continue if param.arraySize: continue - s = param.pyName or param.name + s, param_type = self.parseNameAndType(param.pyName or param.name, param.type) if param.out: - returns.append(s) + if param_type: + returns.append(param_type) else: if param.inOut: - returns.append(s) + if param_type: + returns.append(param_type) if param.default: default = param.default if default in defValueMap: default = defValueMap.get(default) - - s += '=' + '|'.join([_cleanName(x) for x in default.split('|')]) + if param_type: + if default == 'None': + s = f'{s}: Optional[{param_type}]' + else: + s = f'{s}: {param_type}' + default = '|'.join([self.cleanName(x, True) for x in default.split('|')]) + s = f'{s}={default}' + elif param_type: + s = f'{s} : {param_type}' params.append(s) - self.pyArgsString = '(' + ', '.join(params) + ')' - if len(returns) == 1: - self.pyArgsString += ' -> ' + returns[0] - if len(returns) > 1: - self.pyArgsString += ' -> (' + ', '.join(returns) + ')' + self.pyArgsString = f"({', '.join(params)})" + if not returns: + self.pyArgsString = f'{self.pyArgsString} -> None' + elif len(returns) == 1: + self.pyArgsString = f'{self.pyArgsString} -> {returns[0]}' + elif len(returns) > 1: + self.pyArgsString = f"{self.pyArgsString} -> Tuple[{', '.join(returns)}]" def collectPySignatures(self): diff --git a/etgtools/generators.py b/etgtools/generators.py index 7b164c881..ba94a6621 100644 --- a/etgtools/generators.py +++ b/etgtools/generators.py @@ -81,12 +81,15 @@ def _allSpaces(text): return newText -def wrapText(text): +def wrapText(text, dontWrap: str = ''): import textwrap lines = [] tw = textwrap.TextWrapper(width=70, break_long_words=False) for line in text.split('\n'): - lines.append(tw.fill(line)) + if dontWrap and line.lstrip().startswith(dontWrap): + lines.append(line) + else: + lines.append(tw.fill(line)) return '\n'.join(lines) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 42a540f31..8c1bd157e 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -22,6 +22,7 @@ """ import sys, os, re +from typing import Union import etgtools.extractors as extractors import etgtools.generators as generators from etgtools.generators import nci, Utf8EncodingStream, textfile_open @@ -77,6 +78,19 @@ """ +typing_imports = """\ +from __future__ import annotations +from enum import IntEnum, IntFlag, auto +from typing import (Any, overload, TypeAlias, Generic, + Union, Optional, List, Tuple, Callable +) +try: + from typing import ParamSpec +except ImportError: + from typing_extensions import ParamSpec + +""" + #--------------------------------------------------------------------------- def piIgnored(obj): @@ -112,18 +126,21 @@ def _checkAndWriteHeader(destFile, header, docstring): if not SKIP_PI_FILE: _checkAndWriteHeader(destFile_pi, header_pi, module.docstring) + self.writeSection(destFile_pi, 'typing-imports', typing_imports, at_end=False) self.writeSection(destFile_pi, module.name, stream.getvalue()) if not SKIP_PYI_FILE: _checkAndWriteHeader(destFile_pyi, header_pyi, module.docstring) + self.writeSection(destFile_pyi, 'typing-imports', typing_imports, at_end=False) self.writeSection(destFile_pyi, module.name, stream.getvalue()) - def writeSection(self, destFile, sectionName, sectionText): + def writeSection(self, destFile, sectionName, sectionText, at_end = True): """ Read all the lines from destFile, remove those currently between begin/end markers for sectionName (if any), and write the lines back to the file with the new text in sectionText. + `at_end` determines where in the file the section is added when missing """ sectionBeginLine = -1 sectionEndLine = -1 @@ -139,10 +156,23 @@ def writeSection(self, destFile, sectionName, sectionText): sectionEndLine = idx if sectionBeginLine == -1: - # not there already, add to the end - lines.append(sectionBeginMarker + '\n') - lines.append(sectionText) - lines.append(sectionEndMarker + '\n') + if at_end: + # not there already, add to the end + lines.append(sectionBeginMarker + '\n') + lines.append(sectionText) + lines.append(sectionEndMarker + '\n') + else: + # not there already, add to the beginning + # Skip the header + idx = 0 + for idx, line in enumerate(lines): + if not line.startswith('#'): + break + lines[idx+1:idx+1] = [ + sectionBeginMarker + '\n', + sectionText, + sectionEndMarker + '\n', + ] else: # replace the existing lines lines[sectionBeginLine+1:sectionEndLine] = [sectionText] @@ -202,11 +232,42 @@ def generateEnum(self, enum, stream, indent=''): assert isinstance(enum, extractors.EnumDef) if enum.ignored or piIgnored(enum): return + # These enum classes aren't actually accessible from the real wx + # module, o we need to prepend _. But we want to make a type alias to + # the non-prefixed name, so method signatures can reference it without + # any special code, and also allow bare ints as inputs. + if '@' in enum.name or not enum.name: + # Anonymous enum + enum_name = f"_enum_{enum.name.replace('@', '').strip()}" + alias = '' + else: + alias = self.fixWxPrefix(enum.name) + enum_name = f'_{alias}' + if 'Flags' in enum_name: + enum_type = 'IntFlag' + else: + enum_type = 'IntEnum' + # Create the enum definition + stream.write(f'\n{indent}class {enum_name}({enum_type}):\n') + for v in enum.items: + if v.ignored or piIgnored(v): + continue + name = v.pyName or v.name + stream.write(f'{indent} {name} = auto()\n') + # Create the alias if needed + if alias: + stream.write(f'{indent}{alias}: TypeAlias = Union[{enum_name}, int]\n') + # And bring the enum members into global scope. We can't use + # enum.global_enum for this because: + # 1. It's only available on Python 3.11+ + # 2. FixWxPrefix wouldn't be able to pick up the names, since it's + # detecting based on AST parsing, not runtime changes (which + # enum.global_enum performs). for v in enum.items: if v.ignored or piIgnored(v): continue name = v.pyName or v.name - stream.write('%s%s = 0\n' % (indent, name)) + stream.write(f'{indent}{name} = {enum_name}.{name}\n') #----------------------------------------------------------------------- def generateGlobalVar(self, globalVar, stream): @@ -214,22 +275,16 @@ def generateGlobalVar(self, globalVar, stream): if globalVar.ignored or piIgnored(globalVar): return name = globalVar.pyName or globalVar.name + valTyp = 'Any' if guessTypeInt(globalVar): - valTyp = '0' + valTyp = 'int' elif guessTypeFloat(globalVar): - valTyp = '0.0' + valTyp = 'float' elif guessTypeStr(globalVar): - valTyp = '""' - else: - valTyp = globalVar.type - valTyp = valTyp.replace('const ', '') - valTyp = valTyp.replace('*', '') - valTyp = valTyp.replace('&', '') - valTyp = valTyp.replace(' ', '') - valTyp = self.fixWxPrefix(valTyp) - valTyp += '()' - - stream.write('%s = %s\n' % (name, valTyp)) + valTyp = 'str' + elif globalVar.type: + valTyp = self.cleanType(globalVar.type) or valTyp + stream.write(f'{name}: {valTyp}\n') #----------------------------------------------------------------------- def generateDefine(self, define, stream): @@ -237,10 +292,11 @@ def generateDefine(self, define, stream): if define.ignored or piIgnored(define): return # we're assuming that all #defines that are not ignored are integer or string values + name = define.pyName or define.name if '"' in define.value: - stream.write('%s = ""\n' % (define.pyName or define.name)) + stream.write(f'{name}: str\n') else: - stream.write('%s = 0\n' % (define.pyName or define.name)) + stream.write(f'{name}: int\n') #----------------------------------------------------------------------- def generateTypedef(self, typedef, stream, indent=''): @@ -322,7 +378,7 @@ def generatePyClass(self, pc, stream, indent=''): if pc.bases: stream.write('(%s):\n' % ', '.join(pc.bases)) else: - stream.write('(object):\n') + stream.write(':\n') indent2 = indent + ' '*4 if pc.briefDoc: stream.write('%s"""\n' % indent2) @@ -344,29 +400,32 @@ def generatePyClass(self, pc, stream, indent=''): #----------------------------------------------------------------------- - def generateFunction(self, function, stream): + def generateFunction(self, function, stream, is_overload=False): assert isinstance(function, extractors.FunctionDef) if not function.pyName: return + if not is_overload and function.hasOverloads(): + for f in function.overloads: + self.generateFunction(f, stream, True) + stream.write('\n@overload') + elif is_overload: + stream.write('\n@overload') stream.write('\ndef %s' % function.pyName) - if function.hasOverloads(): - stream.write('(*args, **kw)') - else: - argsString = function.pyArgsString - if not argsString: - argsString = '()' - if '->' in argsString: - pos = argsString.find(')') - argsString = argsString[:pos+1] - if '(' != argsString[0]: - pos = argsString.find('(') - argsString = argsString[pos:] - argsString = argsString.replace('::', '.') - stream.write(argsString) + argsString = function.pyArgsString + if not argsString: + argsString = '()' + if '(' != argsString[0]: + pos = argsString.find('(') + argsString = argsString[pos:] + argsString = argsString.replace('::', '.') + stream.write(argsString) stream.write(':\n') - stream.write(' """\n') - stream.write(nci(function.pyDocstring, 4)) - stream.write(' """\n') + if is_overload: + stream.write(' ...\n') + else: + stream.write(' """\n') + stream.write(nci(function.pyDocstring, 4)) + stream.write(' """\n') def generateParameters(self, parameters, stream, indent): @@ -413,8 +472,6 @@ def generateClass(self, klass, stream, indent=''): bases = [self.fixWxPrefix(b, True) for b in bases] stream.write(', '.join(bases)) stream.write(')') - else: - stream.write('(object)') stream.write(':\n') indent2 = indent + ' '*4 @@ -479,24 +536,35 @@ def generateMemberVar(self, memberVar, stream, indent): assert isinstance(memberVar, extractors.MemberVarDef) if memberVar.ignored or piIgnored(memberVar): return - stream.write('%s%s = property(None, None)\n' % (indent, memberVar.name)) + member_type = memberVar.type + if member_type: + member_type = self.cleanType(member_type) + if not member_type: # Unknown type for the member variable + member_type = 'Any' + stream.write(f'{indent}{memberVar.name}: {member_type}\n') def generateProperty(self, prop, stream, indent): assert isinstance(prop, extractors.PropertyDef) - if prop.ignored or piIgnored(prop): - return - stream.write('%s%s = property(None, None)\n' % (indent, prop.name)) + self._generateProperty(prop, stream, indent) def generatePyProperty(self, prop, stream, indent): assert isinstance(prop, extractors.PyPropertyDef) + self._generateProperty(prop, stream, indent) + + def _generateProperty(self, prop: Union[extractors.PyPropertyDef, extractors.PropertyDef], stream, indent: str): if prop.ignored or piIgnored(prop): return - stream.write('%s%s = property(None, None)\n' % (indent, prop.name)) + if prop.setter and prop.getter: + stream.write(f'{indent}{prop.name} = property({prop.getter}, {prop.setter})\n') + elif prop.getter: + stream.write(f'{indent}{prop.name} = property({prop.getter})\n') + elif prop.setter: + stream.write(f'{indent}{prop.name} = property(fset={prop.setter})\n') - def generateMethod(self, method, stream, indent, name=None, docstring=None): + def generateMethod(self, method, stream, indent, name=None, docstring=None, is_overload=False): assert isinstance(method, extractors.MethodDef) for m in method.all(): # use the first not ignored if there are overloads if not m.ignored or piIgnored(m): @@ -512,44 +580,44 @@ def generateMethod(self, method, stream, indent, name=None, docstring=None): name = magicMethods[name] # write the method declaration + if not is_overload and method.hasOverloads(): + for m in method.overloads: + self.generateMethod(m, stream, indent, name, None, True) + stream.write(f'\n{indent}@overload') + elif is_overload: + stream.write(f'\n{indent}@overload') if method.isStatic: stream.write('\n%s@staticmethod' % indent) stream.write('\n%sdef %s' % (indent, name)) - if method.hasOverloads(): - if not method.isStatic: - stream.write('(self, *args, **kw)') + argsString = method.pyArgsString + if not argsString: + argsString = '()' + if '(' != argsString[0]: + pos = argsString.find('(') + argsString = argsString[pos:] + if not method.isStatic: + if argsString == '()': + argsString = '(self)' else: - stream.write('(*args, **kw)') - else: - argsString = method.pyArgsString - if not argsString: - argsString = '()' - if '->' in argsString: - pos = argsString.find(') ->') - argsString = argsString[:pos+1] - if '(' != argsString[0]: - pos = argsString.find('(') - argsString = argsString[pos:] - if not method.isStatic: - if argsString == '()': - argsString = '(self)' - else: - argsString = '(self, ' + argsString[1:] - argsString = argsString.replace('::', '.') - stream.write(argsString) + argsString = '(self, ' + argsString[1:] + argsString = argsString.replace('::', '.') + stream.write(argsString) stream.write(':\n') indent2 = indent + ' '*4 # docstring - if not docstring: - if hasattr(method, 'pyDocstring'): - docstring = method.pyDocstring - else: - docstring = "" - stream.write('%s"""\n' % indent2) - if docstring.strip(): - stream.write(nci(docstring, len(indent2))) - stream.write('%s"""\n' % indent2) + if is_overload: + stream.write(f'{indent2}...\n') + else: + if not docstring: + if hasattr(method, 'pyDocstring'): + docstring = method.pyDocstring + else: + docstring = "" + stream.write('%s"""\n' % indent2) + if docstring.strip(): + stream.write(nci(docstring, len(indent2))) + stream.write('%s"""\n' % indent2) diff --git a/etgtools/sip_generator.py b/etgtools/sip_generator.py index 38db80b1d..56b8e9bfd 100644 --- a/etgtools/sip_generator.py +++ b/etgtools/sip_generator.py @@ -610,7 +610,7 @@ def generateDocstring(self, item, stream, indent): # get the docstring text text = nci(extractors.flattenNode(item.briefDoc, False)) - text = wrapText(text) + text = wrapText(text, item.pyName or item.name) #if isinstance(item, extractors.ClassDef): diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index 5b76888e4..ee5a24ceb 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -14,9 +14,12 @@ import etgtools as extractors from .generators import textfile_open +import keyword +import re import sys, os import copy import textwrap +from typing import Optional, Tuple PY3 = sys.version_info[0] == 3 @@ -98,6 +101,7 @@ def fixWxPrefix(self, name, checkIsCore=False): testName = name if '(' in name: testName = name[:name.find('(')] + testName = testName.split('.')[0] if testName in FixWxPrefix._coreTopLevelNames: return 'wx.'+name @@ -120,6 +124,9 @@ def _processItem(item, names): names.append(item.name) elif isinstance(item, ast.FunctionDef): names.append(item.name) + elif isinstance(item, ast.AnnAssign): + if isinstance(item.target, ast.Name): + names.append(item.target.id) names = list() filename = 'wx/core.pyi' @@ -135,7 +142,102 @@ def _processItem(item, names): FixWxPrefix._coreTopLevelNames = names - + def cleanName(self, name: str, is_expression: bool = False, fix_wx: bool = True) -> str: + """Process a C++ name for use in Python code. In all cases, this means + handling name collisions with Python keywords. For names that will be + used for an identifier (ex: class, method, constant) - `is_expression` + is False - this also includes the reserved constant names 'False', + 'True', and 'None'. When `is_expression` is True, name are allowed to + include special characters and the reserved constant names - this is + intended for cleaning up type-hint expressions ans default value + expressions. + + Finally, the 'wx.' prefix is added if needed. + """ + for txt in ['const', '*', '&', ' ']: + name = name.replace(txt, '') + name = name.replace('::', '.') + if not is_expression: + name = re.sub(r'[^a-zA-Z0-9_\.]', '', name) + if not (is_expression and name in ['True', 'False', 'None']) and keyword.iskeyword(name): + name = f'_{name}' # Python keyword name collision + name = name.strip() + if fix_wx: + return self.fixWxPrefix(name, True) + else: + return removeWxPrefix(name) + + def cleanType(self, type_name: str) -> str: + """Process a C++ type name for use as a type annotation in Python code. + Handles translation of common C++ types to Python types, as well as a + few specific wx types to Python types. + """ + double_type = 'float' if PY3 else 'double' + long_type = 'int' if PY3 else 'long' + type_map = { + # Some types are guesses, marked with TODO to verify automatic + # conversion actually happens. Also, these are the type-names + # after processing by cleanName (so spaces are removed), or + # after potentially lopping off an 'Array' prefix. + # --String types + 'String': 'str', + 'Char': 'str', + 'char': 'str', + 'FileName': 'str', # TODO: check conversion + # --Int types + 'byte': 'int', + 'short': 'int', + 'Int': 'int', + 'unsigned': 'int', + 'unsignedchar': 'int', + 'unsignedshort': 'int', + 'unsignedint': 'int', + 'time_t': 'int', + 'size_t': 'int', + 'Int32': 'int', + 'long': long_type, + 'unsignedlong': long_type, + 'ulong': long_type, + 'LongLong': long_type, + # --Float types + 'double': double_type, + 'Double': double_type, + # --Others + 'void': 'Any', + 'PyObject': 'Any', + 'WindowID': 'int', # defined in wx/defs.h + 'Coord': 'int', # defined in wx/types.h + } + type_name = self.cleanName(type_name) + # Special handling of Vector types - + if type_name.startswith('Vector<') and type_name.endswith('>'): + # Special handling for 'Vector' types + type_name = self.cleanType(type_name[7:-1]) + return f'List[{type_name}]' + if type_name.startswith('Array'): + type_name = self.cleanType(type_name[5:]) + if type_name: + return f'List[{type_name}]' + else: + return 'list' + return type_map.get(type_name, type_name) + + def parseNameAndType(self, name_string: str, type_string: Optional[str]) -> Tuple[str, Optional[str]]: + """Given an identifier name and an optional type annotation, process + these per cleanName and cleanType. Further performs transforms on the + identifier name that may be required due to the type annotation. + Ex. The transformation "any_identifier : ..." -> "*args" requires + modifying both the identifier name and the annotation. + """ + name_string = self.cleanName(name_string, fix_wx=False) + if type_string: + type_string = self.cleanType(type_string) + if type_string == '...': + name_string = '*args' + type_string = None + if not type_string: + type_string = None + return name_string, type_string def ignoreAssignmentOperators(node): diff --git a/requirements/devel.txt b/requirements/devel.txt index cda34ed5e..21f5c1af6 100644 --- a/requirements/devel.txt +++ b/requirements/devel.txt @@ -22,3 +22,4 @@ markupsafe==1.1.1 doc2dash==2.3.0 beautifulsoup4 attrdict3 ; sys_platform == 'win32' +typing-extensions; python_version < '3.10' diff --git a/requirements/install.txt b/requirements/install.txt index 2061eda20..797a72431 100644 --- a/requirements/install.txt +++ b/requirements/install.txt @@ -3,3 +3,4 @@ numpy < 1.17 ; python_version <= '2.7' numpy ; python_version >= '3.0' and python_version < '3.12' # pillow < 3.0 six +typing-extensions; python_version < '3.10' \ No newline at end of file