From 07de1d515e35dc8541aa11960a718003a59a8a05 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 14:49:19 -0600 Subject: [PATCH 01/22] [etgtools] Disable text wrapping for specific lines in docstrings The line-wrapping causes issues once the python signatures become too long, as textwrap isn't smart enough to split the lines on valid continuation points for code. I had one instance of splitting a line in the middle of a string -> SyntaxError on next run of etg due to the generated PYI file having an unterminated string. Specificially, disable splitting for lines that start (ignoring spaces) with a specific string - in this case any line starting with the name of the function or method this is a docstring for. --- etgtools/generators.py | 7 +++++-- etgtools/sip_generator.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/etgtools/generators.py b/etgtools/generators.py index 7b164c88..ba94a662 100644 --- a/etgtools/generators.py +++ b/etgtools/generators.py @@ -81,12 +81,15 @@ def nci(text, numSpaces=0, stripLeading=True): 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/sip_generator.py b/etgtools/sip_generator.py index 38db80b1..56b8e9bf 100644 --- a/etgtools/sip_generator.py +++ b/etgtools/sip_generator.py @@ -610,7 +610,7 @@ from .%s import * # 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): From d303548d436c499e032a91919e54f1ecb7981fe9 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 15:51:32 -0600 Subject: [PATCH 02/22] Move local `_cleanName` to `FixWxPrefix.cleanName` This allows for building `FixWxPrefix.cleanType` on top of it, for use in processing type-hint strings in the future. It also exposes the method to `FunctionDef.makePyArgString` in the future, which has easier access to the types of arguments and returns. And possibly further in the future, other `***Def` classes can make use of it (constant definitions, etc). --- etgtools/extractors.py | 13 ++----- etgtools/tweaker_tools.py | 74 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/etgtools/extractors.py b/etgtools/extractors.py index 8c992cb1..0d776268 100644 --- a/etgtools/extractors.py +++ b/etgtools/extractors.py @@ -466,17 +466,10 @@ class FunctionDef(BaseDef, FixWxPrefix): 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.cleanName(self.type)) defValueMap = { 'true': 'True', 'false': 'False', @@ -523,8 +516,8 @@ class FunctionDef(BaseDef, FixWxPrefix): default = param.default if default in defValueMap: default = defValueMap.get(default) - - s += '=' + '|'.join([_cleanName(x) for x in default.split('|')]) + default = '|'.join([self.cleanName(x, True) for x in default.split('|')]) + s = f'{s}={default}' params.append(s) self.pyArgsString = '(' + ', '.join(params) + ')' diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index 5b76888e..e9448eff 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -14,6 +14,8 @@ stage of the ETG scripts. import etgtools as extractors from .generators import textfile_open +import keyword +import re import sys, os import copy import textwrap @@ -135,7 +137,79 @@ class FixWxPrefix(object): FixWxPrefix._coreTopLevelNames = names + def cleanName(self, name: str, is_expression: bool = False) -> 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() + return self.fixWxPrefix(name, True) + + 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) + # --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 + 'PyObject': 'Any', + 'WindowID': 'int', # defined in wx/defs.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.cleanName(type_name[7:-1]) + return f'list[{type_name}]' + if type_name.startswith('Array'): + type_name = self.cleanName(type_name[5:]) + if type_name: + return f'list[{type_name}]' + else: + return 'list' + return type_map.get(type_name, type_name) def ignoreAssignmentOperators(node): From a28de82bbb0e33ef452937c5dd29783b5ab150ce Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 16:01:10 -0600 Subject: [PATCH 03/22] Ensure needed imports from `typing` are included in type-stubs Leverages the `writeSection` machinery, with a tweak to specify to add a new section to the beginning of a file, after the header. This ensures the required imports gets updated (and also only imported once per file) if new imports are needed for type-hints. Hint: there's a few more to come. --- etgtools/pi_generator.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 42a540f3..4845969c 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -77,6 +77,12 @@ header_pyi = """\ """ +typing_imports = """\ +from __future__ import annotations +from typing import Any + +""" + #--------------------------------------------------------------------------- def piIgnored(obj): @@ -112,18 +118,21 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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 +148,23 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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] From fa2bde419ff62e66d47da25909004151220d2331 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 17:11:26 -0600 Subject: [PATCH 04/22] Extract type information in `makePyArgsString` One unexpected type of '...' required adding a new transformation that modifies both the name and the type to just '*args', so added a preferred method `FixWxPrefix.parseNameAndType` which processes both strings at once. Also fixes cleanType to recursively call cleanType on sub-types (was improperly calling cleanName). With this, method and function signatures now have type annotations which are mostly correct (100% correct in the "it compiles" sense). Thankfully, the incorrect type-hints don't cause errors due to using stringized annotations (by importing annotations from __future__). Importantly, the overload signatures now have been fully sanitized. Before this, there was one instance of a variable named `is`, and another named `/Transfer/` - both invalid identifiers. I stopped looking after those. Since theses signatures are valid Python code, this opens up the opportunity to use `typing.overload` to fully expose those. Edge-cases in type-hints will be addressed in later commits. --- etgtools/extractors.py | 37 +++++++++++++++++++++++++------------ etgtools/tweaker_tools.py | 27 +++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/etgtools/extractors.py b/etgtools/extractors.py index 0d776268..35e12c8f 100644 --- a/etgtools/extractors.py +++ b/etgtools/extractors.py @@ -462,9 +462,6 @@ class FunctionDef(BaseDef, FixWxPrefix): """ 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/ """ params = list() returns = list() @@ -477,6 +474,7 @@ class FunctionDef(BaseDef, FixWxPrefix): 'wxString()': '""', 'wxArrayString()' : '[]', 'wxArrayInt()' : '[]', + 'wxEmptyString': "''", # Makes signatures much shorter } if isinstance(self, CppMethodDef): # rip apart the argsString instead of using the (empty) list of parameters @@ -495,7 +493,14 @@ class FunctionDef(BaseDef, FixWxPrefix): 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}: {arg_type} | None' + else: + arg = f'{arg}: {arg_type}' if default: arg += '=' + default params.append(arg) @@ -506,25 +511,33 @@ class FunctionDef(BaseDef, FixWxPrefix): 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_type: + s = f'{s}: {param_type}' if param.default: default = param.default if default in defValueMap: default = defValueMap.get(default) + if param_type and default == 'None': + s = f'{s} | None' default = '|'.join([self.cleanName(x, True) for x in default.split('|')]) s = f'{s}={default}' 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/tweaker_tools.py b/etgtools/tweaker_tools.py index e9448eff..b587a489 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -169,11 +169,12 @@ class FixWxPrefix(object): 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) + # after processing by cleanName (so spaces are removed), or + # after potentially lopping off an 'Array' prefix. # --String types 'String': 'str', 'Char': 'str', - 'char':' str', + 'char': 'str', 'FileName': 'str', # TODO: check conversion # --Int types 'byte': 'int', @@ -196,20 +197,38 @@ class FixWxPrefix(object): # --Others 'PyObject': 'Any', 'WindowID': 'int', # defined in wx/defs.h + # A few instances, for example in LogInfo: } 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.cleanName(type_name[7:-1]) + type_name = self.cleanType(type_name[7:-1]) return f'list[{type_name}]' if type_name.startswith('Array'): - type_name = self.cleanName(type_name[5:]) + 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: str | None) -> tuple[str, str | None]: + """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) + 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): From 7f74a5fd655894522d2afb3271b4588dd7abcde5 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:40:45 -0600 Subject: [PATCH 05/22] Prepare for changes in generated #define and global variables These will be changing to annotation statements, so FixWxPrefix needs to be able to detect this still (proper thing to look for in this case is `ast.AnnAssign`). --- etgtools/tweaker_tools.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index b587a489..7d13c737 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -122,6 +122,9 @@ class FixWxPrefix(object): 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' From 6b6b810cc4c175edb6b94dd329e1029cb3137139 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 17:20:44 -0600 Subject: [PATCH 06/22] Process overloaded functions and methods --- etgtools/pi_generator.py | 109 +++++++++++++++++++++------------------ 1 file changed, 59 insertions(+), 50 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 4845969c..e8a969c0 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -79,7 +79,7 @@ header_pyi = """\ typing_imports = """\ from __future__ import annotations -from typing import Any +from typing import Any, overload """ @@ -366,29 +366,35 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): #----------------------------------------------------------------------- - 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 '->' in argsString: + pos = argsString.find(')') + argsString = argsString[:pos+1] + 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): @@ -518,7 +524,7 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): stream.write('%s%s = property(None, None)\n' % (indent, prop.name)) - 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): @@ -534,44 +540,47 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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 '->' 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: - 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) From 2c6100ceadcf93b0e9745da9393990c1d0c7d1b8 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 17:28:19 -0600 Subject: [PATCH 07/22] Enable return-type annotations With the work from the previous commits, it's as simple as no longer lopping off the args string at the '->' marker. (And one minor fixup to the makePyArgsString code). --- etgtools/extractors.py | 2 +- etgtools/pi_generator.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/etgtools/extractors.py b/etgtools/extractors.py index 35e12c8f..3eb14eb2 100644 --- a/etgtools/extractors.py +++ b/etgtools/extractors.py @@ -466,7 +466,7 @@ class FunctionDef(BaseDef, FixWxPrefix): params = list() returns = list() if self.type and self.type != 'void': - returns.append(self.cleanName(self.type)) + returns.append(self.cleanType(self.type)) defValueMap = { 'true': 'True', 'false': 'False', diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index e8a969c0..66e4c7bd 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -380,9 +380,6 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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:] @@ -552,9 +549,6 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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:] From 7aad3d4c70e917b94de2594f2338f3de08c24ee7 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:15:19 -0600 Subject: [PATCH 08/22] Better generated properties By directly referencing their setter and getter methods, and due to the typing work already done for methods, we now have type information attached to properties. There are a few edge cases of setters/getters not having the proper number of arguments for a getter(0) or setter(1), but most cases are handled. The incorrect number of arguments may be missing default arguments from what the extraction code figures out from the C++ code? --- etgtools/pi_generator.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 66e4c7bd..c0936f46 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -509,16 +509,22 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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: 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, is_overload=False): From 3e634c079482428600d442432e4e23b5f43f5199 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:22:51 -0600 Subject: [PATCH 09/22] Better generated member variables --- etgtools/pi_generator.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index c0936f46..d9d965af 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -504,7 +504,12 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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): From ec15761d5b7740acfab7158ea74b0343318d60c1 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:34:08 -0600 Subject: [PATCH 10/22] Better generated global variables Use the more generic type rather than a literal type. Before, a type-checker would infer an int defined this way as `Literal[0]` vs the more correctly generic `int` for example. --- etgtools/pi_generator.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index d9d965af..4ca49cec 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -236,22 +236,16 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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): @@ -259,10 +253,11 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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=''): From e14be4fbb4cd858dbf941aa395aee7d064079646 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 18:36:55 -0600 Subject: [PATCH 11/22] Edge case on type-conversions: typdef int wxCoord --- etgtools/tweaker_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index 7d13c737..bce63049 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -200,7 +200,7 @@ class FixWxPrefix(object): # --Others 'PyObject': 'Any', 'WindowID': 'int', # defined in wx/defs.h - # A few instances, for example in LogInfo: + 'Coord': 'int', # defined in wx/types.h } type_name = self.cleanName(type_name) # Special handling of Vector types - From e84d7abcba6c93b348aa70540d67f79c47ea9b17 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 21:34:31 -0600 Subject: [PATCH 12/22] Possible way to handle enums. Named enums with 'Flags' in the name use `enum.IntFlag`, all other enums use `enum.IntEnum`. We have to bring the enum members into the containing scope to match the original behaviour. Further, since these enums are typing information only, and not actually in wx proper, we prevent them from appearing to leak the the user by marking as non-exported (prepend '_' to the name), then make a TypeAlias to the enum or an int. This way type signatures still claim to accept integers as appropriate. I like this solution best, because we preserved the information about which enum members are expected for method/function parameters, while not leaking non-existant classes out of the type-stubs, and not complaining about using integers. There's still a few undefined but referenced enums (ex: richtext.TextAttrDimensionFlags). These are most likely a union of some of the other flags/enum types already defined, but the work to pull that information from the C++ source is probably too much. --- etgtools/pi_generator.py | 36 ++++++++++++++++++++++++++++++++++-- etgtools/tweaker_tools.py | 1 + 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 4ca49cec..1b3764a4 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -79,7 +79,8 @@ header_pyi = """\ typing_imports = """\ from __future__ import annotations -from typing import Any, overload +from enum import IntEnum, IntFlag, auto +from typing import Any, overload, TypeAlias """ @@ -224,11 +225,42 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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('%s%s = 0\n' % (indent, name)) + stream.write(f'{indent} {name} = auto()\n') + # Create the alias if needed + if alias: + stream.write(f'{indent}{alias}: TypeAlias = {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(f'{indent}{name} = {enum_name}.{name}\n') #----------------------------------------------------------------------- def generateGlobalVar(self, globalVar, stream): diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index bce63049..3e6c1c8a 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -100,6 +100,7 @@ class FixWxPrefix(object): testName = name if '(' in name: testName = name[:name.find('(')] + testName = testName.split('.')[0] if testName in FixWxPrefix._coreTopLevelNames: return 'wx.'+name From 3d5290f46ccc6cc3f80786427fa4362eed3f8457 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 21:55:46 -0600 Subject: [PATCH 13/22] Another type-conversion edge case: void -> Any --- etgtools/tweaker_tools.py | 1 + 1 file changed, 1 insertion(+) diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index 3e6c1c8a..cc3d0425 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -199,6 +199,7 @@ class FixWxPrefix(object): '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 From 1fa0df5db534a5d6a4fc9d1957255286f92fd201 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Tue, 17 Oct 2023 21:56:28 -0600 Subject: [PATCH 14/22] Add typing to handwritten code for core.pyi --- etg/_core.py | 28 ++++++++++++++++------------ etgtools/pi_generator.py | 7 +++---- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/etg/_core.py b/etg/_core.py index 04d47438..67acbdc7 100644 --- a/etg/_core.py +++ b/etg/_core.py @@ -291,7 +291,11 @@ def run(): """) - module.addPyFunction('CallAfter', '(callableObj, *args, **kw)', doc="""\ + module.addPyCode("""\ + _T = TypeVar('_T') + _P = ParamSpec('_P') + """) + module.addPyFunction('CallAfter', '(callableObj: 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 +326,7 @@ def run(): wx.PostEvent(app, evt)""") - module.addPyClass('CallLater', ['object'], + module.addPyClass('CallLater', ['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 +346,7 @@ def run(): """, items = [ PyCodeDef('__instances = {}'), - PyFunctionDef('__init__', '(self, millis, callableObj, *args, **kwargs)', + PyFunctionDef('__init__', '(self, millis, callableObj: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ Constructs a new :class:`wx.CallLater` object. @@ -366,7 +370,7 @@ def run(): PyFunctionDef('__del__', '(self)', 'self.Stop()'), - PyFunctionDef('Start', '(self, millis=None, *args, **kwargs)', + PyFunctionDef('Start', '(self, millis: int | None=None, *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ (Re)start the timer @@ -388,7 +392,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 +401,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 +425,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 +433,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 +441,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 +460,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/pi_generator.py b/etgtools/pi_generator.py index 1b3764a4..bac6d3ae 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -79,8 +79,9 @@ header_pyi = """\ typing_imports = """\ from __future__ import annotations +from collections.abc import Callable from enum import IntEnum, IntFlag, auto -from typing import Any, overload, TypeAlias +from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic """ @@ -371,7 +372,7 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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) @@ -465,8 +466,6 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): 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 From 3958d89217739ecac9dc8ef069a6601b7336aace Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 12:17:05 -0600 Subject: [PATCH 15/22] Optional fixing of wx prefix in cleanName This fixes erroneous wx. prepended to `version` variable in methods' parameters. --- etgtools/tweaker_tools.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index cc3d0425..d3a217e9 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -141,7 +141,7 @@ class FixWxPrefix(object): FixWxPrefix._coreTopLevelNames = names - def cleanName(self, name: str, is_expression: bool = False) -> str: + 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` @@ -161,7 +161,10 @@ class FixWxPrefix(object): if not (is_expression and name in ['True', 'False', 'None']) and keyword.iskeyword(name): name = f'_{name}' # Python keyword name collision name = name.strip() - return self.fixWxPrefix(name, True) + 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. @@ -225,7 +228,7 @@ class FixWxPrefix(object): Ex. The transformation "any_identifier : ..." -> "*args" requires modifying both the identifier name and the annotation. """ - name_string = self.cleanName(name_string) + name_string = self.cleanName(name_string, fix_wx=False) if type_string: type_string = self.cleanType(type_string) if type_string == '...': From 17438ac31853a3c6d35b549fb9501032313a4c69 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:06:31 -0600 Subject: [PATCH 16/22] Python <3.10 typing compat: unions Use `typing.Optional` and `typing.Union` where applicable, as direct union (`|`) type annotations were added in Python 3.10 --- etgtools/extractors.py | 13 ++++++++----- etgtools/pi_generator.py | 7 ++++--- etgtools/tweaker_tools.py | 3 ++- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/etgtools/extractors.py b/etgtools/extractors.py index 3eb14eb2..a534f05e 100644 --- a/etgtools/extractors.py +++ b/etgtools/extractors.py @@ -498,7 +498,7 @@ class FunctionDef(BaseDef, FixWxPrefix): arg, arg_type = self.parseNameAndType(arg, arg_type) if arg_type: if default == 'None': - arg = f'{arg}: {arg_type} | None' + arg = f'{arg}: Optional[{arg_type}]' else: arg = f'{arg}: {arg_type}' if default: @@ -519,16 +519,19 @@ class FunctionDef(BaseDef, FixWxPrefix): if param.inOut: if param_type: returns.append(param_type) - if param_type: - s = f'{s}: {param_type}' if param.default: default = param.default if default in defValueMap: default = defValueMap.get(default) - if param_type and default == 'None': - s = f'{s} | None' + 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 = f"({', '.join(params)})" diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index bac6d3ae..1ee4a130 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -22,6 +22,7 @@ want to add some type info to that version of the file eventually... """ 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 @@ -81,7 +82,7 @@ typing_imports = """\ from __future__ import annotations from collections.abc import Callable from enum import IntEnum, IntFlag, auto -from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic +from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional """ @@ -250,7 +251,7 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): stream.write(f'{indent} {name} = auto()\n') # Create the alias if needed if alias: - stream.write(f'{indent}{alias}: TypeAlias = {enum_name} | int\n') + 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+ @@ -547,7 +548,7 @@ class PiWrapperGenerator(generators.WrapperGeneratorBase, FixWxPrefix): assert isinstance(prop, extractors.PyPropertyDef) self._generateProperty(prop, stream, indent) - def _generateProperty(self, prop: extractors.PyPropertyDef | extractors.PropertyDef, stream, indent: str): + def _generateProperty(self, prop: Union[extractors.PyPropertyDef, extractors.PropertyDef], stream, indent: str): if prop.ignored or piIgnored(prop): return if prop.setter and prop.getter: diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index d3a217e9..32f7fef7 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -19,6 +19,7 @@ import re import sys, os import copy import textwrap +from typing import Union PY3 = sys.version_info[0] == 3 @@ -221,7 +222,7 @@ class FixWxPrefix(object): return 'list' return type_map.get(type_name, type_name) - def parseNameAndType(self, name_string: str, type_string: str | None) -> tuple[str, str | None]: + def parseNameAndType(self, name_string: str, type_string: Union[str, None]) -> tuple[str, str | None]: """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. From 2ea917b0b55bfcf4da00d1d13c9f87b66ee6d78d Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:08:54 -0600 Subject: [PATCH 17/22] Python <3.9 typing compat: list Subscribing builtins.list wasn't added until Python 3.9 so use `typing.List` where applicable. --- etgtools/pi_generator.py | 2 +- etgtools/tweaker_tools.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 1ee4a130..b1507d0a 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -82,7 +82,7 @@ typing_imports = """\ from __future__ import annotations from collections.abc import Callable from enum import IntEnum, IntFlag, auto -from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional +from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional, List """ diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index 32f7fef7..2235426d 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -213,11 +213,11 @@ class FixWxPrefix(object): 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}]' + 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}]' + return f'List[{type_name}]' else: return 'list' return type_map.get(type_name, type_name) From b23c5a83dc7e72a227a26cc2b3cd763dfb696683 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:10:24 -0600 Subject: [PATCH 18/22] Python <3.9 typing compat: tuples Subscripting builtins.tuple was added in Python 3.9, so use `typing.Tuple` where applicable. --- etgtools/extractors.py | 2 +- etgtools/pi_generator.py | 2 +- etgtools/tweaker_tools.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/etgtools/extractors.py b/etgtools/extractors.py index a534f05e..071cf484 100644 --- a/etgtools/extractors.py +++ b/etgtools/extractors.py @@ -540,7 +540,7 @@ class FunctionDef(BaseDef, FixWxPrefix): elif len(returns) == 1: self.pyArgsString = f'{self.pyArgsString} -> {returns[0]}' elif len(returns) > 1: - self.pyArgsString = f"{self.pyArgsString} -> tuple[{', '.join(returns)}]" + self.pyArgsString = f"{self.pyArgsString} -> Tuple[{', '.join(returns)}]" def collectPySignatures(self): diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index b1507d0a..6aad3ce4 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -82,7 +82,7 @@ typing_imports = """\ from __future__ import annotations from collections.abc import Callable from enum import IntEnum, IntFlag, auto -from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional, List +from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional, List, Tuple """ diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index 2235426d..dc8eec28 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -19,7 +19,7 @@ import re import sys, os import copy import textwrap -from typing import Union +from typing import Union, Tuple PY3 = sys.version_info[0] == 3 @@ -222,7 +222,7 @@ class FixWxPrefix(object): return 'list' return type_map.get(type_name, type_name) - def parseNameAndType(self, name_string: str, type_string: Union[str, None]) -> tuple[str, str | None]: + def parseNameAndType(self, name_string: str, type_string: Union[str, None]) -> Tuple[str, str | None]: """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. From 804d3f15e394d07d9dacab2eb6012f6daf070cc8 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 14:17:47 -0600 Subject: [PATCH 19/22] fixup: union commit --- etgtools/tweaker_tools.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/etgtools/tweaker_tools.py b/etgtools/tweaker_tools.py index dc8eec28..ee5a24ce 100644 --- a/etgtools/tweaker_tools.py +++ b/etgtools/tweaker_tools.py @@ -19,7 +19,7 @@ import re import sys, os import copy import textwrap -from typing import Union, Tuple +from typing import Optional, Tuple PY3 = sys.version_info[0] == 3 @@ -222,7 +222,7 @@ class FixWxPrefix(object): return 'list' return type_map.get(type_name, type_name) - def parseNameAndType(self, name_string: str, type_string: Union[str, None]) -> Tuple[str, str | None]: + 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. From 1bdc66afc65fa5fb469a042ef5e96f851d4608e6 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:15:25 -0600 Subject: [PATCH 20/22] Python <3.10 typing compat: Callable `collections.abc.Callable` subscripting was added in Python 3.10, use `typing.Callable` instead. --- etgtools/pi_generator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 6aad3ce4..e1c140ca 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -80,9 +80,10 @@ header_pyi = """\ typing_imports = """\ from __future__ import annotations -from collections.abc import Callable from enum import IntEnum, IntFlag, auto -from typing import Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional, List, Tuple +from typing import (Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, + Union, Optional, List, Tuple, Callable +) """ From 51675584d823aabbfe028669c16731d3285f8d60 Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 14:25:09 -0600 Subject: [PATCH 21/22] Tentative: fix for ParamSpec on Python < 3.10 Alternate solution is to remove the callable typing on CallAfter and CallLater --- etgtools/pi_generator.py | 4 ++++ requirements/devel.txt | 1 + requirements/install.txt | 1 + 3 files changed, 6 insertions(+) diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index e1c140ca..85ca6929 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -84,6 +84,10 @@ from enum import IntEnum, IntFlag, auto from typing import (Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, Union, Optional, List, Tuple, Callable ) +try: + from typing import ParamSpec +except ImportError: + from typing_extensions import ParamSpec """ diff --git a/requirements/devel.txt b/requirements/devel.txt index cda34ed5..21f5c1af 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 2061eda2..797a7243 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 From 17cbd0254352a09c7ab9629b7904b8f42d3e5e6d Mon Sep 17 00:00:00 2001 From: lojack5 <1458329+lojack5@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:22:49 -0600 Subject: [PATCH 22/22] fixup: Non-generated core.pyi code Since this is used to not only generate the type-stubs, but also the actual wx/core.py, we need to ensure TypeVar and ParamSpec are imported there as well. So, move the imports to the wx/core.py generator definition, and remove the imports from the type-stub generator (these imports are only used by CallAfter and CallLater). --- etg/_core.py | 19 ++++++++++++------- etgtools/pi_generator.py | 2 +- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/etg/_core.py b/etg/_core.py index 67acbdc7..3addfb5f 100644 --- a/etg/_core.py +++ b/etg/_core.py @@ -291,11 +291,16 @@ def run(): """) + module.addPyCode('import typing', order=10) module.addPyCode("""\ - _T = TypeVar('_T') - _P = ParamSpec('_P') - """) - module.addPyFunction('CallAfter', '(callableObj: Callable[_P, _T], *args: _P.args, **kw: _P.kwargs) -> None', doc="""\ + _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 @@ -326,7 +331,7 @@ def run(): wx.PostEvent(app, evt)""") - module.addPyClass('CallLater', ['Generic[_P, _T]'], + 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 @@ -346,7 +351,7 @@ def run(): """, items = [ PyCodeDef('__instances = {}'), - PyFunctionDef('__init__', '(self, millis, callableObj: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> None', + PyFunctionDef('__init__', '(self, millis, callableObj: typing.Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ Constructs a new :class:`wx.CallLater` object. @@ -370,7 +375,7 @@ def run(): PyFunctionDef('__del__', '(self)', 'self.Stop()'), - PyFunctionDef('Start', '(self, millis: int | None=None, *args: _P.args, **kwargs: _P.kwargs) -> None', + PyFunctionDef('Start', '(self, millis: typing.Optional[int]=None, *args: _P.args, **kwargs: _P.kwargs) -> None', doc="""\ (Re)start the timer diff --git a/etgtools/pi_generator.py b/etgtools/pi_generator.py index 85ca6929..8c1bd157 100644 --- a/etgtools/pi_generator.py +++ b/etgtools/pi_generator.py @@ -81,7 +81,7 @@ header_pyi = """\ typing_imports = """\ from __future__ import annotations from enum import IntEnum, IntFlag, auto -from typing import (Any, overload, TypeAlias, TypeVar, ParamSpec, Generic, +from typing import (Any, overload, TypeAlias, Generic, Union, Optional, List, Tuple, Callable ) try: