diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac63cb4..1903d4d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Check configured mypy target and API compatibility (Python 3.13) if: matrix.python == '3.8' run: uv run --isolated --python 3.13 --extra dev python checks/mypy_compatibility.py -v + - name: Establish configured package mypy baseline + if: matrix.python == '3.8' + run: uv run --extra dev python -m mypy --config-file pyproject.toml --package installer_builder - name: Build wheel and source distribution run: uv build - name: Require both package artifacts diff --git a/agent-notes/repair/task6-installer-mypy-baseline.md b/agent-notes/repair/task6-installer-mypy-baseline.md new file mode 100644 index 0000000..1f77834 --- /dev/null +++ b/agent-notes/repair/task6-installer-mypy-baseline.md @@ -0,0 +1,183 @@ +# Goal + +Resolve the full configured installer_builder typing baseline after the bounded +range/target compatibility change landed separately in #7. Preserve package +>=3.8.6, mypy target 3.8, public behavior and every existing strict setting. + +# State + +Published PR #5 remains open and unmerged, with full typing work remaining. +repair/task6-installer-mypy-baseline was genuinely rebased onto merged master +fad12d9a3eafa59fcd6033e54534c4eee3ed2fb7 using --no-update-refs. The last +recorded completed measurement is run 39563222831 at published source head +bbf7a823aab11ea59728e580217d5c0f81d7aafb, detailed below. Live PR checks are +authoritative for later heads; that measurement is not a promised later result. +The baseline gate remains unchanged in the existing Windows Python 3.8 leg. +The merged base supplies mypy>=0.991,<1.15 and the unchanged three-case +target/API/config probes on tool Python 3.8 and 3.13. The authorized source +repair now annotates the four production modules and adds only the proven +Python 3.8-compatible types-setuptools development stub. The completed first +annotation measurement remains the last recorded result; no green result is inferred for +this source candidate. No full package checker command has run locally. + +# Decisions and why + +- The new explicit baseline scope is `python -m mypy --config-file pyproject.toml + --package installer_builder`, invoked through `uv run --extra dev`. This is + newly wired coverage of all four production modules, not a previously + authoritative command or a tiny probe claimed to cover the whole package. +- Originally CI installed pytest through the dependency group without selecting + the optional mypy extra. The landed #7 now selects that extra for its bounded + compatibility probes. This separate full-package step uses the same extra on + the existing 3.8 runtime, honoring the unchanged 3.8 target. +- Diagnostics fail normally. No continue-on-error, ignored diagnostic codes, + altered strict flags, target substitution, skip or mass annotation is added. + Existing source contains unannotated definitions; hosted CI must establish + actual diagnostics before deciding any bounded baseline repair. + The latest recorded run 39563222831 at bbf7a823 established the diagnostic + snapshot detailed in Verification below. +- The existing Windows 3.8/3.14 test/build axes, check names, action pins, + permissions, checkout credentials policy, timeout and concurrency remain. + A failing baseline step prevents that leg's later build in the usual way; + it is not a green baseline and does not justify suppressing the failure. +- The landed compatibility probes and dependency narrowing are separate source + intent, now inherited once each from actual master. Those green bounded + config/target/API probes are not full-package typecheck evidence. The rebase + resolved one adjacent-step workflow conflict by keeping both unchanged probes + followed by exactly one existing full-package command; no other hunk changed. + +# Typing repair decisions and actual boundaries + +- Annotate all 89 previously reported definitions and the six untyped container + sites with Python 3.8 typing forms: concrete string collections, data-file + tuples, generators, paths, optional lifecycle state and return types. Keep + implicit None fallthroughs explicit without adding new fallback values. +- Path aliases quote os.PathLike[str] so alias evaluation does not require + Python 3.9 runtime generics. Actual os/path-only input surfaces accept string + or PathLike paths; legacy chop/dist_dir operations still require strings. + Application.name is a read-only protocol property; srcname accepts modules, + dotted strings and the existing __file__ duck-typed input. InnoScript metadata + is object because it is inspected with dir/getattr and existing tests supply + SimpleNamespace, not only DistributionMetadata. +- Command dist_dir remains Optional during initialize_options. The narrow + casts at run/signing use the real distutils lifecycle: Distribution.run_command + calls ensure_finalized before run, and each finalize_options supplies dist. + Direct invalid calls retain their old failures; no new runtime guard is added. + Likewise build() sets build_start_time before both reporting paths and calls + build_installer's existing name validation before finalization. Casts express + those cross-method facts without changing initialization or validation. +- Keep Any only at actual external dynamic boundaries: extensible setuptools/ + py2exe keyword dictionaries, forwarded application **kwargs, public metadata + dir/getattr values and native registry values/defaults. Internal file lists, + command options, translations, script lines and generated metadata are typed. +- Optional Babel/pytz/certifi/enchant helpers consume only package __path__ or + __file__, not provider APIs. Use the existing importlib.import_module pattern + and stdlib module metadata types. Each real import still executes at the same + feature point and fails normally when absent; no provider or native Enchant + installation is added solely to satisfy a checker. File-backed provider path + casts retain the same invalid-metadata failures rather than inventing paths. +- Add types-setuptools==75.8.0.20250110 only to the existing optional dev extra. + Its official PyPI metadata requires >=3.8, has no dependencies and records + testing with mypy 1.14.1. It types actual setup/find_packages APIs; no runtime + dependency, mypy option, ignored module, coverage or workflow is weakened. + On supported Python >=3.8, import builtins directly under the existing alias + rather than probing the obsolete Python 2 __builtin__ module. +- No runtime defect was established in this repair. The small executable edits + are import spelling, identity casts, local variable separation for registry + root names/handles, and explicit existing None fallthroughs. No new signing, + build, compiler, native, data-generation or optional-feature policy is added. +- First hosted annotation feedback left six errors. Remove the now-proven + redundant dottedname cast and spell the three existing bare returns as None. + DistributionMetadata name/version are genuinely nullable; do not cast away + that state or use getters that invent UNKNOWN/0.0.0. At the final join boundary + explicitly narrow both captured values to str, retaining TypeError for invalid + values with join's existing sequence-item/type-name message. Valid string + metadata comes from the existing Distribution producer in test_new_inno.py; + the type checks preserve the accepted input set instead of asserting that + every Distribution always has valid metadata. This exposes the existing + string-join contract without a new runtime feature or a suppression. + +# Preservation + +backup/task6-installer-mypy-original-pr preserves original PR #4 head +62d473dd04ad154d3b4aade2b8723dc76ce73909. Canonical local master remains +099ceb30876aba2b853a8bb98815cba3bb19f3e9 with all untracked user environments, +history, lock and source files preserved. Other branches/worktrees/backups +are untouched. Named archive/task6-installer-mypy-baseline-before-11ec preserves +748ddadfa400a596a12a03e32fc1923c99705dd5 and +archive/task6-installer-mypy-compatibility-before-11ec preserves +e89bff687780f0aa2a426f2047cb4467a917f584. No remote PR branch or default branch +was changed by this local rebase. Named +archive/task6-installer-mypy-baseline-before-fad12 preserves published +2f18a7922a3bec7ca31f9fadf633081403aad973. The redundant stacked probe branch +stays unchanged at fd0103dc83f89e66c12b413e87d6fa724cbfbd91; it was not rebased +or forced through duplicate conflicts. The PR #4 branch was not modified by +this task. Dependabot independently refreshed it to +995f935b95fbe64004cb47fc779af9f13a589e3a with <2.5 after #7; coordinator +verified run 39554719205 fails two 3.13 probe cases with mypy 2.4. The original +62d473dd archive remains preserved; no result for that separate head is implied +by this #5 source repair. +These earlier archive tips are historical. Named +archive/task6-installer-mypy-baseline-measurement-cde0f5c8 preserves the exact +cde0f5c85b442e727583f06a6eeec352eaa0003a measured tip before this note correction. +archive/task6-installer-mypy-baseline-before-typing preserves exact +76332cebdb1eaac1400427daf73b3b4bafd87b6c before these source annotations. +archive/task6-installer-mypy-baseline-first-typing-bbf7a82 preserves exact +bbf7a823aab11ea59728e580217d5c0f81d7aafb before the focused six-error follow-up. + +# Verification + +Historically, actionlint 1.7.12 passed with optional shellcheck/pyflakes disabled +and zizmor 1.31.1 offline pedantic reported no findings for the unchanged added +workflow step. Hosted run 39548810883 at old head 748ddadf failed the full +configured package check with 115 errors in four files using the existing <1.0 +tool; its Python 3.14 tests/build passed. The next published baseline 2f18a792 +had 112 diagnostics with mypy 0.991 in run 39552363115 after the three helper +diagnostics disappeared; both 71-test suites passed. These are historical +results under the old <1.0 range. Actual #7 +landed the narrower compatible <1.15 range and bounded probes separately. +The historical pre-annotation measurement, run 39555011151 at exact cde0f5c8, +selected mypy 1.14.1 and failed with 106 errors in four package files. +Diagnostic classes: 89 no-untyped-def, 6 var-annotated, 3 import-untyped, +3 import-not-found, 3 arg-type, 1 assignment and 1 operator. +Both 71-test suites passed, as did all three compatibility cases on tool Python 3.8 +and 3.13. The Python 3.8 build was skipped after the full-check failure; the +Python 3.14 build succeeded. This remains a red full-package baseline. Actual +review approved the workflow source hunk and requested only this stale-note +correction. No local full checker, tests/build or installer/signing action ran, +including for this note-only correction. +At the recorded cde measurement, delta inspection confirmed the same three-line +unsuppressed gate without source/test/manifest/probe/strict-setting changes. +After resolving the adjacent-step conflict, actionlint 1.7.12 again exited 0 +and offline pedantic zizmor 1.31.1 reported no findings. No local test or +checker execution was added to that static workflow verification. + +For this source candidate, every existing production definition was inspected +and annotated, including all reported no-untyped-def/container sites. Known +import complaints are addressed through the real setuptools stub, supported +builtins import and module-path boundaries. Optional path/timing/platform +assignments are typed according to their actual lifecycles. This is an estimate +of addressed source sites, not a checker result or count prediction: newly +exposed precise-type diagnostics may need hosted feedback. No local checker, +tests/build, optional provider import or native action ran. No regression was +added merely to mirror annotations; existing 71 tests and both three-case +compatibility probes remain intact. The workflow is byte-for-byte unchanged. + +Last recorded completed source measurement on 2026-10-21: run 39563222831 at +exact bbf7a823aab11ea59728e580217d5c0f81d7aafb. Both 71-test suites passed; +all three probe cases passed with mypy 1.14.1 on Python 3.8.20 and 3.13.16. +The Python 3.14 build passed; the full checker remained red with six errors in +two files (two list-item, one redundant-cast, three return-value). Python 3.8 +build was skipped after that failure. The focused follow-up addresses those +exact six source sites; its result must come from the next hosted run. No +local checker, tests/build, provider import, signing or native action ran. + +# Next step + +Return this focused two-source-file/note follow-up for coordinator review +before guarded publication and the next hosted full-package measurement. +Resolve any remaining accurate-type diagnostics under the unchanged full gate; +do not suppress, weaken flags or shrink coverage. Keep original #4, the redundant +stacked candidate and canonical work unchanged. PR #5 remains open/unmerged; +no merge until the relevant full check is green and source review is complete. +Live checks govern later heads. No model, remote write or merge ran here. diff --git a/installer_builder/__init__.py b/installer_builder/__init__.py index 376ea76..9bc5f6f 100644 --- a/installer_builder/__init__.py +++ b/installer_builder/__init__.py @@ -1,13 +1,10 @@ # * Encoding: UTF-8 -from __future__ import print_function +from __future__ import annotations, print_function import setuptools -try: - import __builtin__ -except ImportError: - import builtins as __builtin__ +import builtins as __builtin__ import collections import datetime import fnmatch @@ -20,6 +17,18 @@ import subprocess import sys import time +from typing import TYPE_CHECKING, Any, Callable, DefaultDict, Dict, Iterator, List, Mapping, Optional, Protocol, Set, Tuple, Type, Union, cast + +if TYPE_CHECKING: + from distutils.core import Command + +DataFiles = List[Tuple[str, List[str]]] +Path = Union[str, "os.PathLike[str]"] + + +class Application(Protocol): + @property + def name(self) -> str: ... is_windows = platform.system() == "Windows" is_mac = platform.system() == "Darwin" @@ -49,35 +58,35 @@ class InstallerBuilder(object): def __init__( self, - main_module=None, - name=None, - version=None, - url=None, - author=None, - author_email=None, - datafiles=None, - includes=None, - excludes=None, - dll_excludes=None, - compressed=False, - skip_archive=False, - bundle_level=3, - optimization_level=1, - extra_packages=None, - datafile_packages=None, - output_directory="release", - create_update=False, - postbuild_commands=None, - osx_frameworks=None, - extra_inno_script=None, - register_startup=False, - localized_packages=None, - has_translations=False, - certificate_file=None, - certificate_password=None, - extra_files_to_sign=None, - app_type="windows", - ): + main_module: Optional[Path] = None, + name: Optional[str] = None, + version: Optional[str] = None, + url: Optional[str] = None, + author: Optional[str] = None, + author_email: Optional[str] = None, + datafiles: Optional[DataFiles] = None, + includes: Optional[List[str]] = None, + excludes: Optional[List[str]] = None, + dll_excludes: Optional[List[str]] = None, + compressed: bool = False, + skip_archive: bool = False, + bundle_level: int = 3, + optimization_level: int = 1, + extra_packages: Optional[List[str]] = None, + datafile_packages: Optional[List[str]] = None, + output_directory: Path = "release", + create_update: bool = False, + postbuild_commands: Optional[Mapping[str, List[str]]] = None, + osx_frameworks: Optional[List[str]] = None, + extra_inno_script: Optional[str] = None, + register_startup: bool = False, + localized_packages: Optional[List[str]] = None, + has_translations: bool = False, + certificate_file: Optional[Path] = None, + certificate_password: Optional[str] = None, + extra_files_to_sign: Optional[List[Path]] = None, + app_type: str = "windows", + ) -> None: super(InstallerBuilder, self).__init__() self.main_module = main_module self.name = name @@ -114,13 +123,13 @@ def __init__( self.create_update = create_update if postbuild_commands is None: postbuild_commands = {} - self.postbuild_commands = collections.defaultdict(list) + self.postbuild_commands: DefaultDict[str, List[str]] = collections.defaultdict(list) self.postbuild_commands.update(postbuild_commands) if osx_frameworks is None: osx_frameworks = [] self.osx_frameworks = osx_frameworks self.extra_inno_script = extra_inno_script - self.build_start_time = None + self.build_start_time: Optional[float] = None self.register_startup = register_startup if localized_packages is None: localized_packages = [] @@ -135,14 +144,14 @@ def __init__( raise ValueError("Invalid app type") self.app_type = app_type - def get_version_specific_excludes(self): + def get_version_specific_excludes(self) -> List[str]: result = [] version = float("%d.%d" % (sys.version_info.major, sys.version_info.minor)) if version < 3.5: result.append("jinja2.asyncsupport") return result - def build(self, skip_finalize=False): + def build(self, skip_finalize: bool = False) -> None: self.build_start_time = time.time() self.prebuild_message() self.remove_previous_build() @@ -159,7 +168,7 @@ def build(self, skip_finalize=False): ) self.report_build_time() - def _installer_was_created(self): + def _installer_was_created(self) -> bool: """Check if an installer file was actually created by the build process""" try: installer_path = self.find_created_installer() @@ -168,14 +177,14 @@ def _installer_was_created(self): # If installer doesn't exist, no installer was created return False - def prebuild_message(self): + def prebuild_message(self) -> None: print("Installer builder version %s" % __version__) print( "Building %s installer for %s %s" % (platform.system(), self.name, self.version) ) - def remove_previous_build(self): + def remove_previous_build(self) -> None: print("Removing previous output directories") directories = self.build_dirs + [self.output_directory] for directory in directories: @@ -185,8 +194,8 @@ def remove_previous_build(self): shutil.rmtree(directory, ignore_errors=False) print("Deleted ", directory) - def find_datafiles(self): - datafiles = [] + def find_datafiles(self) -> DataFiles: + datafiles: DataFiles = [] for package in self.datafile_packages: pkg_datafile_function = DATAFILE_REGISTRY.get(package) if pkg_datafile_function is None: @@ -221,14 +230,14 @@ def find_datafiles(self): print("Total datafiles to be included: %d" % total_datafiles) return self.datafiles + datafiles - def find_application_language_data(self): + def find_application_language_data(self) -> Iterator[Tuple[str, List[str]]]: self._validate_translation_inventory( self.locale_dir, expected_domain=self.name ) for directory, filenames in self.find_locale_data(self.locale_dir): yield directory, filenames - def _validate_translation_inventory(self, locale_path, expected_domain=None): + def _validate_translation_inventory(self, locale_path: Path, expected_domain: Optional[str] = None) -> Tuple[str, ...]: application_mode = expected_domain is not None catalog_extensions = (".po", ".mo") if application_mode else (".mo",) catalogs = [] @@ -287,7 +296,7 @@ def _validate_translation_inventory(self, locale_path, expected_domain=None): % (catalog_directory, expected_domain, found_domain) ) - inventory = {} + inventory: Dict[Tuple[str, str], Set[str]] = {} for _, directory, stem, extension in catalogs: inventory.setdefault((directory, stem), set()).add(extension) @@ -321,17 +330,17 @@ def _validate_translation_inventory(self, locale_path, expected_domain=None): return tuple(sorted({stem for _, _, stem, extension in catalogs if extension == ".mo"})) - def find_babel_datafiles(self): - import babel + def find_babel_datafiles(self) -> Tuple[Tuple[str, List[str]], ...]: + babel = importlib.import_module("babel") return ( ( "locale-data", - glob.glob(os.path.join(babel.__path__[0], "locale-data", "*.*")), + glob.glob(os.path.join(cast(List[str], babel.__path__)[0], "locale-data", "*.*")), ), ) - def find_locale_data(self, locale_path): + def find_locale_data(self, locale_path: Path) -> Iterator[Tuple[str, List[str]]]: for dirpath, dirnames, filenames in os.walk(locale_path): files = [] for filename in filenames: @@ -344,12 +353,12 @@ def find_locale_data(self, locale_path): ) yield directory, files - def finalize_build(self): + def finalize_build(self) -> None: print("Finalizing build...") if platform.system() == "Darwin": self.remove_embedded_interpreter() self.shrink_mac_binaries() - self.lipo_file(os.path.join(self.get_app_path(), self.name)) + self.lipo_file(os.path.join(self.get_app_path(), cast(str, self.name))) self.create_dmg() # Only move output if installer was created @@ -362,35 +371,35 @@ def finalize_build(self): if self.create_update: self.create_update_archive() - def remove_embedded_interpreter(self): + def remove_embedded_interpreter(self) -> None: print("Replacing the embedded interpreter with a dumby file") interpreter_path = os.path.join(self.get_app_path(), "python") os.remove(interpreter_path) self.execute_command("touch %s" % interpreter_path) self.execute_command("chmod +x %s" % interpreter_path) - def get_app_path(self): + def get_app_path(self) -> Path: if platform.system() == "Darwin": return os.path.join( self.dist_dir, "%s.app" % self.name, "Contents", "MacOS" ) return self.dist_dir - def create_dmg(self): + def create_dmg(self) -> None: print("Creating .dmg disk image") self.execute_command( "hdiutil create -srcfolder dist/%s.app -size 150m dist/%s" % (self.name, self.installer_filename()) ) - def move_output(self): + def move_output(self) -> None: if not os.path.exists(self.output_directory): os.mkdir(self.output_directory) destination = os.path.join(self.output_directory, self.installer_filename()) os.rename(self.find_created_installer(), destination) print("Moved generated installer to %s" % destination) - def create_update_archive(self): + def create_update_archive(self) -> None: print("Generating update archive") name = "%s-%s-%s" % (self.name, self.version, platform.system()) root_dir = self.dist_dir @@ -404,7 +413,7 @@ def create_update_archive(self): os.rename(filename, destination) print("Generated update archive filename: %s" % destination) - def find_created_installer(self): + def find_created_installer(self) -> str: res = os.path.join("dist", self.installer_filename()) if not os.path.exists(res): res = os.path.join(self.output_directory, self.installer_filename()) @@ -412,7 +421,7 @@ def find_created_installer(self): raise RuntimeError("Installer %s does not exist" % res) return res - def installer_filename(self): + def installer_filename(self) -> str: if platform.system() == "Windows": return "%s-%s-setup.exe" % (self.name, self.version) elif platform.system() == "Darwin": @@ -421,7 +430,7 @@ def installer_filename(self): # Fallback for other systems return "%s-%s-installer" % (self.name, self.version) - def get_command_class(self): + def get_command_class(self) -> Optional[Type[Command]]: if platform.system() == "Windows": from .new_inno_command import NewInnoSetupCommand @@ -430,18 +439,19 @@ def get_command_class(self): import py2app.build_app return py2app.build_app.py2app + return None - def perform_postbuild_commands(self): + def perform_postbuild_commands(self) -> None: if not self.postbuild_commands[platform.system().lower()]: return print("Performing postbuild commands for platform %s" % platform.system()) for command in self.postbuild_commands[platform.system().lower()]: self.execute_command(command) - def execute_command(self, command): + def execute_command(self, command: str) -> None: subprocess.check_call([command], shell=True) - def report_build_statistics(self): + def report_build_statistics(self) -> None: try: installer_path = self.find_created_installer() print("Generated installer filename: %s" % installer_path) @@ -453,7 +463,7 @@ def report_build_statistics(self): print("Build completed - no installer created (py2exe/py2app only)") self.report_build_time() - def shrink_mac_binaries(self): + def shrink_mac_binaries(self) -> None: shrink_extensions = (".dylib", ".so") for basepath, dirs, files in os.walk(self.dist_dir): for file in files: @@ -461,16 +471,17 @@ def shrink_mac_binaries(self): path = os.path.join(basepath, file) self.lipo_file(path) - def lipo_file(self, filename): + def lipo_file(self, filename: Path) -> None: self.execute_command("lipo -thin i386 %s -output %s" % (filename, filename)) print("Lipoed file %s" % filename) - def report_build_time(self): - build_time = time.time() - self.build_start_time + def report_build_time(self) -> None: + # build() records this before either path that reports elapsed time. + build_time = time.time() - cast(float, self.build_start_time) td = datetime.timedelta(seconds=build_time) print("Build completed in ", format(td)) - def build_installer(self): + def build_installer(self) -> None: if None in (self.name, self.main_module): raise RuntimeError("Insufficient information provided to build") if ( @@ -481,7 +492,8 @@ def build_installer(self): self.certificate_password = os.environ.get( "CERTIFICATE_PASS" ) or getpass.getpass("Certificate password:") - setup_arguments = { + # setuptools/py2exe accept heterogeneous, extensible keyword payloads. + setup_arguments: Dict[str, Any] = { "name": self.name, "author": self.author, "author_email": self.author_email, @@ -566,15 +578,16 @@ def build_installer(self): return setuptools.setup(**setup_arguments) - def get_copyright(self): + def get_copyright(self) -> str: return "Copyright ©%d %s" % (datetime.date.today().year, self.author) class AppInstallerBuilder(InstallerBuilder): - def __init__(self, application=None, **kwargs): + def __init__(self, application: Optional[Application] = None, **kwargs: Any) -> None: self.application = application - new_kwargs = {} - new_kwargs["name"] = application.name + # Application providers and forwarded builder keyword options are dynamic. + new_kwargs: Dict[str, Any] = {} + new_kwargs["name"] = cast(Application, application).name new_kwargs["version"] = getattr(application, "version", None) new_kwargs["url"] = getattr(application, "website", None) new_kwargs["author"] = getattr(application, "author", None) @@ -593,16 +606,16 @@ def __init__(self, application=None, **kwargs): localized_packages = kwargs.get("localized_packages", []) config_spec = getattr(application, "config_spec", None) if config_spec is True: - config_spec = "%s.confspec" % application.name + config_spec = "%s.confspec" % cast(Application, application).name if config_spec is not None: datafiles.extend([("", [config_spec])]) - import babel + babel = importlib.import_module("babel") - datafiles.extend([("babel", [os.path.join(babel.__path__[0], "global.dat")])]) - from certifi import __file__ as cert_path + datafiles.extend([("babel", [os.path.join(cast(List[str], babel.__path__)[0], "global.dat")])]) + cert_path = importlib.import_module("certifi").__file__ datafiles.extend( - [("", [os.path.join(os.path.dirname(cert_path), "cacert.pem")])] + [("", [os.path.join(os.path.dirname(cast(str, cert_path)), "cacert.pem")])] ) kwargs["datafiles"] = datafiles if getattr(application, "output", False): @@ -636,7 +649,7 @@ def __init__(self, application=None, **kwargs): kwargs["extra_files_to_sign"] = files_to_sign new_kwargs.update(kwargs) if hasattr(application, "register_startup"): - new_kwargs["register_startup"] = application.register_startup + new_kwargs["register_startup"] = getattr(application, "register_startup") if hasattr(application, "debug_port") or hasattr(application, "debug_host"): if sys.version_info.major < 3: includes.append("SocketServer") # not picked up on Mac @@ -646,21 +659,22 @@ def __init__(self, application=None, **kwargs): super(AppInstallerBuilder, self).__init__(**new_kwargs) -def format_filesize(num): +def format_filesize(num: float) -> Optional[str]: for x in ["bytes", "KB", "MB", "GB", "TB"]: if num < 1024.0: return "%3.1f %s" % (num, x) num /= 1024.0 + return None -def standard_wx_excludes(): +def standard_wx_excludes() -> List[str]: return [ "wx.py", "wx.stc", ] -def sqlite_sqlalchemy_excludes(): +def sqlite_sqlalchemy_excludes() -> List[str]: return [ "sqlalchemy.testing", "sqlalchemy.dialects.postgresql", @@ -673,7 +687,7 @@ def sqlite_sqlalchemy_excludes(): ] -def app_framework_excludes(): +def app_framework_excludes() -> List[str]: return [ "watchdog", "yappi", @@ -683,7 +697,7 @@ def app_framework_excludes(): ] -def stdlib_excludes(pdb=True): +def stdlib_excludes(pdb: bool = True) -> List[str]: res = [ "doctest", "email.test", @@ -698,7 +712,7 @@ def stdlib_excludes(pdb=True): return res -def win32_excludes(): +def win32_excludes() -> List[str]: return [ "win32pipe", "win32wnet", @@ -706,7 +720,7 @@ def win32_excludes(): ] -def get_datafiles(directory="share", match="*", target_path=None): +def get_datafiles(directory: Path = "share", match: str = "*", target_path: Optional[str] = None) -> DataFiles: """builds list of data files to be included with data_files in setuptools A typical task in a setup.py file is to set the path and name of a list of data files to provide with the package. For instance files in share/data @@ -741,10 +755,10 @@ def get_datafiles(directory="share", match="*", target_path=None): return datafiles -def pytz_datafiles(): - import pytz +def pytz_datafiles() -> DataFiles: + pytz = importlib.import_module("pytz") - path = os.path.join(os.path.split(pytz.__file__)[0], "zoneinfo") + path = os.path.join(os.path.split(cast(str, pytz.__file__))[0], "zoneinfo") print("Collecting pytz datafiles from: %s" % path) files = get_datafiles(path, "*") index = path.index("zoneinfo") @@ -753,10 +767,10 @@ def pytz_datafiles(): return files -def enchant_datafiles(): - import enchant +def enchant_datafiles() -> DataFiles: + enchant = importlib.import_module("enchant") - enchant_path = os.path.split(enchant.__file__)[0] + enchant_path = os.path.split(cast(str, enchant.__file__))[0] print("Collecting enchant datafiles from: %s" % enchant_path) dll_files = get_datafiles(enchant_path, "*.dll") @@ -779,7 +793,7 @@ def enchant_datafiles(): return files -DATAFILE_REGISTRY = { +DATAFILE_REGISTRY: Dict[str, Callable[[], DataFiles]] = { "enchant": enchant_datafiles, "pytz": pytz_datafiles, } diff --git a/installer_builder/innosetup.py b/installer_builder/innosetup.py index 89142bd..d99d5ea 100644 --- a/installer_builder/innosetup.py +++ b/installer_builder/innosetup.py @@ -165,7 +165,7 @@ """ -from __future__ import absolute_import, print_function +from __future__ import absolute_import, annotations, print_function import distutils.command import distutils.core @@ -184,6 +184,15 @@ import shutil from xml.etree import ElementTree from zipfile import ZIP_DEFLATED, ZipFile +from types import ModuleType +from typing import Any, Dict, IO, Iterable, Iterator, List, Optional, Protocol, Tuple, Union, cast + +Path = Union[str, "os.PathLike[str]"] + + +class SourceModule(Protocol): + @property + def __file__(self) -> Optional[str]: ... import win32api # for read pe32 resource @@ -272,7 +281,7 @@ """ -def manifest(name, res_id=1): +def manifest(name: Optional[str], res_id: int = 1) -> Tuple[int, int, bytes]: """Create a manifest resource with the given name.""" data = MANIFEST_TEMPLATE % name return RT_MANIFEST, res_id, data.encode('utf-8') @@ -281,24 +290,25 @@ def manifest(name, res_id=1): README_EXT = ".html" -def load_manifest(handle): +def load_manifest(handle: int) -> Optional[str]: """get the first manifest string from HMODULE""" for restype in (RT_MANIFEST,): # win32api.EnumResourceTypes(handle) for name in win32api.EnumResourceNames(handle, restype): return win32api.LoadResource(handle, restype, name).decode("utf_8") + return None -def srcname(dottedname): +def srcname(dottedname: Union[str, ModuleType, SourceModule]) -> str: """get the source filename from importable name or module""" if hasattr(dottedname, "__file__"): - module = dottedname + module = cast(SourceModule, dottedname) else: names = dottedname.split(".") module = __import__(names.pop(0)) for name in names: module = getattr(module, name) - filename = module.__file__ + filename = cast(str, module.__file__) name, ext = os.path.splitext(filename) ext = ext.lower() @@ -315,34 +325,34 @@ def srcname(dottedname): raise ValueError(f"Source file not found for {dottedname}") -def srcnames(*args): +def srcnames(*args: Union[str, ModuleType, SourceModule]) -> List[str]: """get the source filename from importable name or module""" return [srcname(i) for i in args] -def modname(handle): +def modname(handle: int) -> str: """get module filename from HMODULE""" b = ctypes.create_unicode_buffer("", 1024) ctypes.windll.kernel32.GetModuleFileNameW(handle, b, 1024) return b.value -def findfiles(filenames, *conditions): +def findfiles(filenames: Iterable[str], *conditions: str) -> List[str]: """filter `filenames` by conditions""" - def check(filename): + def check(filename: str) -> Optional[bool]: filename = filename.lower() for i in conditions: i = i.lower() if i.startswith("."): # compare ext if os.path.splitext(filename)[1] != i: - return + return None elif i.count(".") == 1: # compare basename if os.path.basename(filename) != i: - return + return None else: # contains if i not in os.path.basename(filename): - return + return None return True return [i for i in filenames if check(i)] @@ -358,7 +368,7 @@ def check(filename): } -def getregvalue(path, default=None): +def getregvalue(path: str, default: Any = None) -> Any: """get registry value noname value @@ -369,11 +379,11 @@ def getregvalue(path, default=None): >>> getregvalue('HKEY_CLASSES_ROOT\\.py\\Content Type') ''text/plain """ - root, subkey = path.split("\\", 1) - if root.startswith("HKEY_"): - root = getattr(winreg, root) - elif root in hkshortnames: - root = hkshortnames[root] + root_name, subkey = path.split("\\", 1) + if root_name.startswith("HKEY_"): + root = getattr(winreg, root_name) + elif root_name in hkshortnames: + root = hkshortnames[root_name] else: root = winreg.HKEY_CURRENT_USER subkey = path @@ -395,7 +405,7 @@ class IssFile(io.TextIOWrapper): "Flags", ] - def __init__(self, filename, mode='w', encoding='utf-8'): + def __init__(self, filename: Path, mode: str = 'w', encoding: str = 'utf-8') -> None: # Open a buffered binary file and wrap it binary_file = open(filename, mode.replace('t', '') + 'b') super().__init__(binary_file, encoding=encoding) @@ -403,7 +413,7 @@ def __init__(self, filename, mode='w', encoding='utf-8'): if 'w' in mode and encoding.lower() == 'utf-8': self.write('\ufeff') # UTF-8 BOM - def issline(self, **kwargs): + def issline(self, **kwargs: object) -> None: args = [] for k, v in kwargs.items(): if k not in self.noescape: @@ -464,11 +474,11 @@ class InnoScript(object): ".dll", ".pyd", ) - iss_metadata = {} + iss_metadata: Dict[str, str] = {} - def __init__(self, dist_dir, metadata, inno_script, inno_setup_exe=None, - bundle_vcr=True, register_startup=False, zip_option=False, - extra_inno_script=None): + def __init__(self, dist_dir: str, metadata: object, inno_script: str, inno_setup_exe: Optional[Path] = None, + bundle_vcr: Union[bool, str] = True, register_startup: Union[bool, str] = False, zip_option: Union[bool, str] = False, + extra_inno_script: Optional[str] = None) -> None: """Initialize the InnoScript with the necessary parameters. Args: @@ -503,10 +513,10 @@ def __init__(self, dist_dir, metadata, inno_script, inno_setup_exe=None, # Scan the dist directory to find files self.created_files = self._scan_dist_dir() - def parse_iss(self, s): + def parse_iss(self, s: str) -> Iterator[Tuple[str, str, List[str]]]: firstline = "" sectionname = "" - lines = [] + lines: List[str] = [] for line in s.splitlines(): if line.startswith("[") and "]" in line: if lines: @@ -519,7 +529,7 @@ def parse_iss(self, s): if lines: yield firstline, sectionname, lines - def chop(self, filename, dirname=""): + def chop(self, filename: str, dirname: str = "") -> str: """get relative path""" if not dirname: dirname = self.dist_dir @@ -531,9 +541,9 @@ def chop(self, filename, dirname=""): # filename = os.path.basename(filename) return filename - def _scan_dist_dir(self): + def _scan_dist_dir(self) -> Dict[str, List[str]]: """Scan the dist directory to categorize files.""" - results = { + results: Dict[str, List[str]] = { 'executables': [], 'windows_exes': [], 'com_servers': [], @@ -572,7 +582,7 @@ def _scan_dist_dir(self): return results @property - def metadata(self): + def metadata(self) -> Dict[str, Any]: """Get metadata as a dictionary.""" metadata = {} for attr in dir(self._metadata): @@ -582,7 +592,7 @@ def metadata(self): return metadata @property - def appid(self): + def appid(self) -> str: """Generate a consistent AppID based on metadata.""" m = self.metadata if m["url"]: @@ -597,13 +607,13 @@ def appid(self): return f"{{{appid}}}" @property - def iss_consts(self): + def iss_consts(self) -> Dict[str, str]: """Get constants for the ISS file.""" metadata = self.metadata return dict((k, v % metadata) for k, v in self.consts_map.items()) @property - def innoexepath(self): + def innoexepath(self) -> Path: """Find the Inno Setup compiler executable.""" if self._inno_setup_exe and os.path.isfile(self._inno_setup_exe): return self._inno_setup_exe @@ -639,7 +649,7 @@ def innoexepath(self): return "ISCC.exe" @property - def msvcfiles(self): + def msvcfiles(self) -> List[str]: """Get MSVC runtime files if needed. Note: For modern Python (3.5+), this is usually not needed as py2exe @@ -680,11 +690,11 @@ def msvcfiles(self): return files_to_include - def handle_iss(self, lines, fp): + def handle_iss(self, lines: Iterable[str], fp: IO[str]) -> None: for line in lines: fp.write(line + "\n") - def handle_iss_setup(self, lines, fp): + def handle_iss_setup(self, lines: Iterable[str], fp: IO[str]) -> None: metadata = self.metadata iss_metadata = dict((k, v % metadata) for k, v in self.metadata_map.items()) @@ -750,7 +760,7 @@ def handle_iss_setup(self, lines, fp): fp.write("\n") - def handle_iss_files(self, lines, fp): + def handle_iss_files(self, lines: List[str], fp: IssFile) -> None: """Handle the [Files] section of the ISS file.""" files = [] excludes = [] @@ -792,7 +802,7 @@ def handle_iss_files(self, lines, fp): if filename.startswith(self.dist_dir): place = os.path.dirname(relname) - extraargs = {} + extraargs: Dict[str, object] = {} else: # isdir if filename.startswith(self.dist_dir): place = relname @@ -809,7 +819,7 @@ def handle_iss_files(self, lines, fp): self.handle_iss(lines, fp) - def _iter_bin_files(self, category, lines=[]): + def _iter_bin_files(self, category: str, lines: List[str] = []) -> Iterator[Tuple[str, str]]: """Iterate over binary files of a specific category.""" for filename in self.created_files.get(category, []): relname = self.chop(filename) @@ -817,7 +827,7 @@ def _iter_bin_files(self, category, lines=[]): continue yield filename, relname - def handle_iss_run(self, lines, fp): + def handle_iss_run(self, lines: List[str], fp: IssFile) -> None: """Handle the [Run] section of the ISS file.""" # Process COM servers for _, filename in self._iter_bin_files("com_servers", lines): @@ -861,7 +871,7 @@ def handle_iss_run(self, lines, fp): self.handle_iss(lines, fp) - def handle_iss_uninstallrun(self, lines, fp): + def handle_iss_uninstallrun(self, lines: List[str], fp: IssFile) -> None: """Handle the [UninstallRun] section of the ISS file.""" # Process COM servers for _, filename in self._iter_bin_files("com_servers", lines): @@ -905,7 +915,7 @@ def handle_iss_uninstallrun(self, lines, fp): self.handle_iss(lines, fp) - def handle_iss_icons(self, lines, fp): + def handle_iss_icons(self, lines: List[str], fp: IssFile) -> None: """Handle the [Icons] section of the ISS file.""" # Find the main executable to use for shortcuts main_exe = None @@ -944,7 +954,7 @@ def handle_iss_icons(self, lines, fp): self.handle_iss(lines, fp) - def handle_iss_tasks(self, lines, fp): + def handle_iss_tasks(self, lines: List[str], fp: IssFile) -> None: """Handle the [Tasks] section of the ISS file.""" fp.issline( Name="desktopicon", @@ -961,7 +971,7 @@ def handle_iss_tasks(self, lines, fp): self.handle_iss(lines, fp) - def handle_iss_registry(self, lines, fp): + def handle_iss_registry(self, lines: List[str], fp: IssFile) -> None: """Handle the [Registry] section of the ISS file.""" if self.register_startup: # Find the main executable @@ -986,7 +996,7 @@ def handle_iss_registry(self, lines, fp): self.handle_iss(lines, fp) - def handle_iss_languages(self, lines, fp): + def handle_iss_languages(self, lines: List[str], fp: IssFile) -> None: self.handle_iss(lines, fp) if lines: @@ -1003,11 +1013,11 @@ def handle_iss_languages(self, lines, fp): MessagesFile="compiler:%s" % filename, ) - def handle_iss_code(self, lines, fp): + def handle_iss_code(self, lines: List[str], fp: IssFile) -> None: self.handle_iss(lines, fp) fp.write(DEFAULT_CODES) - def create(self): + def create(self) -> None: """Create the Inno Setup script file.""" fp = IssFile(self.issfile, "wt") fp.write('; This file is created by py2exe InnoSetup extension.\n') @@ -1047,7 +1057,7 @@ def create(self): handler([], fp) fp.write("\n") - def compile_script(self): + def compile_script(self) -> str: """Compile the Inno Setup script into an installer.""" try: subprocess.check_call([self.innoexepath, self.issfile]) @@ -1075,14 +1085,14 @@ def compile_script(self): return setupfile @property - def setup_file_path(self): + def setup_file_path(self) -> str: return os.path.join( self.output_dir, self.iss_metadata.get("OutputBaseFilename", "setup") + ".exe", ) @property - def output_dir(self): + def output_dir(self) -> str: return self.iss_metadata.get( "OutputDir", os.path.join(os.path.dirname(self.issfile), "Output") ) @@ -1108,20 +1118,20 @@ class innosetup(distutils.core.Command): boolean_options = ["bundle_vcr", "zip", "register_startup"] - def initialize_options(self): + def initialize_options(self) -> None: """Initialize command options.""" - self.inno_setup_exe = None - self.inno_script = DEFAULT_ISS - self.extra_inno_script = None - self.certificate_file = None - self.certificate_password = None - self.extra_sign = [] - self.bundle_vcr = False # Default to False for modern Python - self.zip = False - self.register_startup = False - self.dist_dir = None + self.inno_setup_exe: Optional[Path] = None + self.inno_script: Optional[str] = DEFAULT_ISS + self.extra_inno_script: Optional[str] = None + self.certificate_file: Optional[Path] = None + self.certificate_password: Optional[str] = None + self.extra_sign: Union[str, List[Path]] = [] + self.bundle_vcr: Union[bool, str] = False # Default to False for modern Python + self.zip: Union[bool, str] = False + self.register_startup: Union[bool, str] = False + self.dist_dir: Optional[str] = None - def finalize_options(self): + def finalize_options(self) -> None: """Finalize command options.""" self.set_undefined_options('bdist', ('dist_dir', 'dist_dir')) if self.dist_dir is None: @@ -1147,7 +1157,7 @@ def finalize_options(self): if isinstance(self.extra_sign, str): self.extra_sign = [self.extra_sign] - def _find_inno_setup(self): + def _find_inno_setup(self) -> Optional[Path]: """Find the Inno Setup compiler.""" if self.inno_setup_exe and os.path.isfile(self.inno_setup_exe): return self.inno_setup_exe @@ -1181,7 +1191,7 @@ def _find_inno_setup(self): return None # Not found - def run(self): + def run(self) -> None: """Run the command: build the installer.""" # First, run py2exe to create the executable self.run_command('py2exe') @@ -1211,7 +1221,7 @@ def run(self): # Create the InnoScript instance script = InnoScript( - dist_dir=self.dist_dir, + dist_dir=cast(str, self.dist_dir), # finalized by distutils before run() metadata=self.distribution.metadata, inno_script=inno_script_content, inno_setup_exe=inno_exe_path, @@ -1235,10 +1245,11 @@ def run(self): if self.certificate_file: self.sign_executable(setup_file) - def sign_executables(self): + def sign_executables(self) -> None: """Sign all executables in the dist directory.""" # Find all executables in the dist directory - for root, _, files in os.walk(self.dist_dir): + dist_dir = cast(str, self.dist_dir) # finalized before run() + for root, _, files in os.walk(dist_dir): for file in files: if file.lower().endswith('.exe'): self.sign_executable(os.path.join(root, file)) @@ -1246,9 +1257,9 @@ def sign_executables(self): # Sign any extra files specified if self.extra_sign: for extra in self.extra_sign: - self.sign_executable(os.path.join(self.dist_dir, extra)) + self.sign_executable(os.path.join(dist_dir, extra)) - def sign_executable(self, exepath): + def sign_executable(self, exepath: Path) -> None: """Sign a single executable.""" if not os.path.exists(exepath): raise FileNotFoundError(f"File to sign not found: {exepath}") diff --git a/installer_builder/new_inno_command.py b/installer_builder/new_inno_command.py index b6a6a17..3aa7894 100644 --- a/installer_builder/new_inno_command.py +++ b/installer_builder/new_inno_command.py @@ -1,16 +1,32 @@ +from __future__ import annotations + import distutils.core +from distutils.dist import Distribution import os import pathlib import platform +from types import ModuleType +from typing import TYPE_CHECKING, List, Optional, Protocol, Union, cast + +if TYPE_CHECKING: + import innosetup_builder + +Path = Union[str, "os.PathLike[str]"] + + +class InstallerCommand(Protocol): + distribution: Distribution + register_startup: bool # Only import Windows-specific modules on Windows if platform.system() == "Windows": - from . import signtool + from . import signtool as _signtool + signtool: Optional[ModuleType] = _signtool else: signtool = None -def create_installer_config(builder_instance, dist_dir): +def create_installer_config(builder_instance: InstallerCommand, dist_dir: Path) -> innosetup_builder.Installer: """Convert InstallerBuilder config to innosetup_builder.Installer""" import innosetup_builder @@ -29,9 +45,15 @@ def create_installer_config(builder_instance, dist_dir): installer.run_at_startup = builder_instance.register_startup # Set output filename + app_name, app_version = installer.app_name, installer.app_version + # The existing join requires strings; retain its TypeError for invalid metadata. + if not isinstance(app_name, str): + raise TypeError(f"sequence item 0: expected str instance, {type(app_name).__name__} found") + if not isinstance(app_version, str): + raise TypeError(f"sequence item 1: expected str instance, {type(app_version).__name__} found") installer_filename = "-".join([ - installer.app_name, - installer.app_version, + app_name, + app_version, 'setup' ]) installer.output_base_filename = installer_filename @@ -53,20 +75,20 @@ class NewInnoSetupCommand(distutils.core.Command): ("dist-dir=", "d", "directory to put final built distributions in"), ] - def initialize_options(self): - self.extra_inno_script = None - self.certificate_file = None - self.certificate_password = None - self.extra_sign = [] + def initialize_options(self) -> None: + self.extra_inno_script: Optional[str] = None + self.certificate_file: Optional[Path] = None + self.certificate_password: Optional[str] = None + self.extra_sign: List[Path] = [] self.register_startup = False - self.dist_dir = None + self.dist_dir: Optional[Path] = None - def finalize_options(self): + def finalize_options(self) -> None: self.set_undefined_options('bdist', ('dist_dir', 'dist_dir')) if self.dist_dir is None: self.dist_dir = "dist" - def run(self): + def run(self) -> None: # Sign executables if requested if self.certificate_file: self._sign_executables() @@ -78,22 +100,25 @@ def run(self): if self.certificate_file: self._sign_installer() - def _create_installer(self): + def _create_installer(self) -> None: import innosetup_builder # Create installer config from distribution metadata - installer_config = create_installer_config(self, self.dist_dir) + # distutils finalizes options before run(); finalize_options supplies dist. + dist_dir = cast(Path, self.dist_dir) + installer_config = create_installer_config(self, dist_dir) # Create compiler and build installer innosetup_compiler = innosetup_builder.InnosetupCompiler() - innosetup_compiler.build(installer_config, self.dist_dir) + innosetup_compiler.build(installer_config, dist_dir) output_name = f"{installer_config.app_name}-{installer_config.app_version}-setup.exe" print(f"Created installer: {output_name}") - def _sign_executables(self): + def _sign_executables(self) -> None: """Sign all executables in dist directory""" - for root, _, files in os.walk(self.dist_dir): + dist_dir = cast(Path, self.dist_dir) # finalized before run() + for root, _, files in os.walk(dist_dir): for file in files: if file.lower().endswith('.exe'): self._sign_file(os.path.join(root, file)) @@ -101,15 +126,15 @@ def _sign_executables(self): # Sign extra files if specified if self.extra_sign: for extra in self.extra_sign: - self._sign_file(os.path.join(self.dist_dir, extra)) + self._sign_file(os.path.join(dist_dir, extra)) - def _sign_installer(self): + def _sign_installer(self) -> None: """Sign the created installer""" installer_name = f"{self.distribution.metadata.name}-{self.distribution.metadata.version}-setup.exe" - installer_path = os.path.join(self.dist_dir, installer_name) + installer_path = os.path.join(cast(Path, self.dist_dir), installer_name) self._sign_file(installer_path) - def _sign_file(self, filepath): + def _sign_file(self, filepath: Path) -> None: """Sign a single file""" if not os.path.exists(filepath): raise FileNotFoundError(f"File to sign not found: {filepath}") diff --git a/installer_builder/signtool.py b/installer_builder/signtool.py index b565d66..19dfa4a 100644 --- a/installer_builder/signtool.py +++ b/installer_builder/signtool.py @@ -1,10 +1,11 @@ -from __future__ import print_function +from __future__ import annotations, print_function import os import subprocess import sys import platform import logging import winreg +from typing import Optional, Union # Set up module logger logger = logging.getLogger(__name__) @@ -13,11 +14,11 @@ class SignToolNotFoundError(Exception): """Exception raised when signtool.exe cannot be found.""" - def __init__(self, message="Could not find signtool.exe. You may need to install the Windows SDK or Visual Studio."): + def __init__(self, message: str = "Could not find signtool.exe. You may need to install the Windows SDK or Visual Studio.") -> None: self.message = message super().__init__(self.message) -def find_signtool(): +def find_signtool() -> str: """ Find signtool.exe on the system, preferring architecture match and newest version. @@ -206,7 +207,7 @@ def find_signtool(): # Return the best match path return best_match[0] -def sign(filename, url='', description='', timestamp_server=DEFAULT_TIMESTAMP_SERVER, certificate_file='', certificate_password=''): +def sign(filename: Union[str, os.PathLike[str]], url: Optional[str] = '', description: str = '', timestamp_server: str = DEFAULT_TIMESTAMP_SERVER, certificate_file: Optional[Union[str, os.PathLike[str]]] = '', certificate_password: Optional[str] = '') -> int: """ Sign a Windows executable or DLL using signtool. diff --git a/pyproject.toml b/pyproject.toml index c4c3ac7..40189e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "mypy>=0.991,<1.15", + "types-setuptools==75.8.0.20250110", ] [dependency-groups]