From 91614cee1f2295e681fb9d9d88b895e3ee162011 Mon Sep 17 00:00:00 2001 From: Jamil Hajjar Date: Mon, 2 Oct 2023 00:27:34 +0200 Subject: [PATCH] add type hints (#109) * add type hints * finish with types and add py.typed file * replace pipe by union and list by typing.List * missing Union * use Set instead of set for typing * fix typing and format issue * fix typing for python 3.8 for tuple and list --- .gitignore | 1 + scooby/__main__.py | 5 ++- scooby/knowledge.py | 28 +++++++------- scooby/py.typed | 0 scooby/report.py | 92 ++++++++++++++++++++++++++------------------- scooby/tracker.py | 29 ++++++++++---- setup.py | 1 + 7 files changed, 93 insertions(+), 63 deletions(-) create mode 100644 scooby/py.typed diff --git a/.gitignore b/.gitignore index 8072ba6..d48e6aa 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ coverage.xml *,cover .tox +venv \ No newline at end of file diff --git a/scooby/__main__.py b/scooby/__main__.py index 47d4127..ec56dea 100644 --- a/scooby/__main__.py +++ b/scooby/__main__.py @@ -2,12 +2,13 @@ import argparse import importlib import sys +from typing import Any, Dict, List, Optional import scooby from scooby.report import Report -def main(args=None): +def main(args: Optional[List[str]] = None): """Parse command line inputs of CLI interface.""" # If not explicitly called, catch arguments if args is None: @@ -51,7 +52,7 @@ def main(args=None): act(vars(parser.parse_args(args))) -def act(args_dict): +def act(args_dict: Dict[str, Any]) -> None: """Act upon CLI inputs.""" # Quick exit if only scooby version. if args_dict.pop('version'): diff --git a/scooby/knowledge.py b/scooby/knowledge.py index 646eba9..caca6c6 100644 --- a/scooby/knowledge.py +++ b/scooby/knowledge.py @@ -11,6 +11,7 @@ import os import sys import sysconfig +from typing import Callable, Dict, List, Literal, Set, Tuple, Union # Define unusual version locations VERSION_ATTRIBUTES = { @@ -21,7 +22,7 @@ } -def get_pyqt5_version(): +def get_pyqt5_version() -> str: """Return the PyQt5 version.""" try: from PyQt5.Qt import PYQT_VERSION_STR @@ -31,13 +32,13 @@ def get_pyqt5_version(): return PYQT_VERSION_STR -VERSION_METHODS = { +VERSION_METHODS: Dict[str, Callable[[], str]] = { 'PyQt5': get_pyqt5_version, } # Check the environments -def in_ipython(): +def in_ipython() -> bool: """Check if we are in a IPython environment. Returns @@ -52,7 +53,7 @@ def in_ipython(): return False -def in_ipykernel(): +def in_ipykernel() -> bool: """Check if in a ipykernel (most likely Jupyter) environment. Warning @@ -70,13 +71,13 @@ def in_ipykernel(): ipykernel = False if in_ipython(): try: - ipykernel = type(get_ipython()).__module__.startswith('ipykernel.') + ipykernel: bool = type(get_ipython()).__module__.startswith('ipykernel.') except NameError: pass return ipykernel -def get_standard_lib_modules(): +def get_standard_lib_modules() -> Set[str]: """Return a set of the names of all modules in the standard library.""" site_path = sysconfig.get_path('stdlib') if getattr(sys, 'frozen', False): # within pyinstaller @@ -86,11 +87,7 @@ def get_standard_lib_modules(): else: names = [] - stdlib_pkgs = [] - for name in names: - if name.endswith(".py"): - stdlib_pkgs.append(name[:-3]) - stdlib_pkgs = set(stdlib_pkgs) + stdlib_pkgs = {name[:-3] for name in names if name.endswith(".py")} else: names = os.listdir(site_path) @@ -119,7 +116,7 @@ def get_standard_lib_modules(): return stdlib_pkgs -def version_tuple(v): +def version_tuple(v: str) -> Tuple[int, ...]: """Convert a version string to a tuple containing ints. Non-numeric version strings will be converted to 0. For example: @@ -138,7 +135,7 @@ def version_tuple(v): if len(split_v) > 3: raise ValueError('Version strings containing more than three parts ' 'cannot be parsed') - vals = [] + vals: List[int] = [] for item in split_v: if item.isnumeric(): vals.append(int(item)) @@ -148,7 +145,7 @@ def version_tuple(v): return tuple(vals) -def meets_version(version, meets): +def meets_version(version: str, meets: str) -> bool: """Check if a version string meets a minimum version. This is a simplified way to compare version strings. For a more robust @@ -193,7 +190,7 @@ def meets_version(version, meets): return True -def get_filesystem_type(): +def get_filesystem_type() -> Union[str, Literal[False]]: """Get the type of the file system at the path of the scooby package.""" try: import psutil # lazy-load see PR#85 @@ -203,6 +200,7 @@ def get_filesystem_type(): import platform # lazy-load see PR#85 # Skip Windows due to https://github.com/banesullivan/scooby/issues/75 + fs_type: Union[str, Literal[False]] if psutil and platform.system() != 'Windows': # Code by https://stackoverflow.com/a/35291824/10504481 my_path = str(Path(__file__).resolve()) diff --git a/scooby/py.typed b/scooby/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/scooby/report.py b/scooby/report.py index 2f41653..09a2678 100644 --- a/scooby/report.py +++ b/scooby/report.py @@ -4,6 +4,7 @@ import sys import time from types import ModuleType +from typing import Any, Dict, List, Literal, Optional, Tuple, Union, cast from .knowledge import ( VERSION_ATTRIBUTES, @@ -22,8 +23,13 @@ class PlatformInfo: """Internal helper class to access details about the computer platform.""" + def __init__(self): + """Initialize.""" + self._mkl_info: Optional[str] # for typing purpose + self._filesystem: Union[str, Literal[False]] + @property - def system(self): + def system(self) -> str: """Return the system/OS name. E.g. ``'Linux'``, ``'Windows'``, or ``'Java'``. An empty string is @@ -32,12 +38,12 @@ def system(self): return platform().system() @property - def platform(self): + def platform(self) -> str: """Return the platform.""" return platform().platform() @property - def machine(self): + def machine(self) -> str: """Return the machine type, e.g. 'i386'. An empty string is returned if the value cannot be determined. @@ -45,12 +51,12 @@ def machine(self): return platform().machine() @property - def architecture(self): + def architecture(self) -> str: """Return the bit architecture used for the executable.""" return platform().architecture()[0] @property - def cpu_count(self): + def cpu_count(self) -> int: """Return the number of CPUs in the system.""" if not hasattr(self, '_cpu_count'): import multiprocessing # lazy-load see PR#85 @@ -59,7 +65,7 @@ def cpu_count(self): return self._cpu_count @property - def total_ram(self): + def total_ram(self) -> str: """Return total RAM info. If not available, returns 'unknown'. @@ -76,7 +82,7 @@ def total_ram(self): return self._total_ram @property - def mkl_info(self): + def mkl_info(self) -> Optional[str]: """Return MKL info. If not available, returns 'unknown'. @@ -97,21 +103,21 @@ def mkl_info(self): # Get mkl info from numexpr or mkl, if available if mkl: - self._mkl_info = mkl.get_version_string() + self._mkl_info = cast(str, mkl.get_version_string()) elif numexpr: - self._mkl_info = numexpr.get_vml_version() + self._mkl_info = cast(str, numexpr.get_vml_version()) else: self._mkl_info = None return self._mkl_info @property - def date(self): + def date(self) -> str: """Return the date formatted as a string.""" return time.strftime('%a %b %d %H:%M:%S %Y %Z') @property - def filesystem(self): + def filesystem(self) -> Union[str, Literal[False]]: """Get the type of the file system at the path of the scooby package.""" if not hasattr(self, '_filesystem'): self._filesystem = get_filesystem_type() @@ -121,9 +127,15 @@ def filesystem(self): class PythonInfo: """Internal helper class to access Python info and package versions.""" - def __init__(self, additional, core, optional, sort): + def __init__( + self, + additional: Optional[List[Union[str, ModuleType]]], + core: Optional[List[Union[str, ModuleType]]], + optional: Optional[List[Union[str, ModuleType]]], + sort: bool, + ): """Initialize python info.""" - self._packages = {} # Holds name of packages and their version + self._packages: Dict[str, Any] = {} # Holds name of packages and their version self._sort = sort # Add packages in the following order: @@ -131,11 +143,13 @@ def __init__(self, additional, core, optional, sort): self._add_packages(core) # Provided by a module dev self._add_packages(optional, optional=True) # Optional packages - def _add_packages(self, packages, optional=False): + def _add_packages( + self, packages: Optional[List[Union[str, ModuleType]]], optional: bool = False + ): """Add all packages to list; optional ones only if available.""" # Ensure arguments are a list if isinstance(packages, (str, ModuleType)): - pckgs = [ + pckgs: List[Union[str, ModuleType]] = [ packages, ] elif packages is None or len(packages) < 1: @@ -150,12 +164,12 @@ def _add_packages(self, packages, optional=False): self._packages[name] = version @property - def sys_version(self): + def sys_version(self) -> str: """Return the system version.""" return sys.version @property - def python_environment(self): + def python_environment(self) -> Literal['Jupyter', 'IPython', 'Python']: """Return the python environment.""" if in_ipykernel(): return 'Jupyter' @@ -164,7 +178,7 @@ def python_environment(self): return 'Python' @property - def packages(self): + def packages(self) -> Dict[str, Any]: """Return versions of all packages. Includes available and unavailable/unknown. @@ -172,7 +186,7 @@ def packages(self): """ pckg_dict = dict(self._packages) if self._sort: - packages = {} + packages: Dict[str, Any] = {} for name in sorted(pckg_dict.keys(), key=lambda x: x.lower()): packages[name] = pckg_dict[name] pckg_dict = packages @@ -209,7 +223,7 @@ class Report(PlatformInfo, PythonInfo): sort : bool, optional Sort the packages when the report is shown. - extra_meta : tuple(str, str), optional + extra_meta : tuple(tuple(str, str), ...), optional Additional two component pairs of meta information to display. max_width : int, optional @@ -219,15 +233,15 @@ class Report(PlatformInfo, PythonInfo): def __init__( self, - additional=None, - core=None, - optional=None, - ncol=4, - text_width=80, - sort=False, - extra_meta=None, - max_width=None, - ): + additional: Optional[List[Union[str, ModuleType]]] = None, + core: Optional[List[Union[str, ModuleType]]] = None, + optional: Optional[List[Union[str, ModuleType]]] = None, + ncol: int = 4, + text_width: int = 80, + sort: bool = False, + extra_meta: Optional[Union[Tuple[Tuple[str, str], ...], List[Tuple[str, str]]]] = None, + max_width: Optional[int] = None, + ) -> None: """Initialize report.""" # Set default optional packages to investigate if optional is None: @@ -250,7 +264,7 @@ def __init__( extra_meta = [] self._extra_meta = extra_meta - def __repr__(self): + def __repr__(self) -> str: """Return Plain-text version information.""" import textwrap # lazy-load see PR#85 @@ -302,12 +316,12 @@ def __repr__(self): return text - def _repr_html_(self): + def _repr_html_(self) -> str: """Return HTML-rendered version information.""" # Define html-styles border = "border: 1px solid;'" - def colspan(html, txt, ncol, nrow): + def colspan(html: str, txt: str, ncol: int, nrow: int) -> str: r"""Print txt in a row spanning whole table.""" html += " \n" html += " Optional[str]: """Use package-resources to get the distribution version.""" try: from pkg_resources import DistributionNotFound, get_distribution except ImportError: - return + return None try: return get_distribution(name).version except (DistributionNotFound, Exception): # pragma: no cover @@ -432,7 +446,7 @@ def pkg_resources_version_fallback(name): # This functionaliy might also be of interest on its own. -def get_version(module): +def get_version(module: Union[str, ModuleType]) -> Tuple[str, Optional[str]]: """Get the version of ``module`` by passing the package or it's name. Parameters @@ -505,7 +519,7 @@ def get_version(module): return name, VERSION_NOT_FOUND -def platform(): +def platform() -> ModuleType: """Return platform as lazy load; see PR#85.""" import platform diff --git a/scooby/tracker.py b/scooby/tracker.py index 327968d..635bc36 100644 --- a/scooby/tracker.py +++ b/scooby/tracker.py @@ -1,4 +1,7 @@ """Track imports.""" +from types import ModuleType +from typing import List, Mapping, Optional, Sequence, Set, Union + from scooby.knowledge import get_standard_lib_modules from scooby.report import Report @@ -15,7 +18,7 @@ pass # The variable we track all imports in -TRACKED_IMPORTS = ["scooby"] +TRACKED_IMPORTS: List[Union[str, ModuleType]] = ["scooby"] MODULES_TO_IGNORE = { "pyMKL", @@ -25,10 +28,10 @@ } -STDLIB_PKGS = None +STDLIB_PKGS: Optional[Set[str]] = None -def _criterion(name): +def _criterion(name: str): if ( len(name) > 0 and name not in STDLIB_PKGS @@ -41,7 +44,13 @@ def _criterion(name): if TRACKING_SUPPORTED: - def scooby_import(name, globals=None, locals=None, fromlist=(), level=0): + def scooby_import( + name: str, + globals: Optional[Mapping[str, object]] = None, + locals: Optional[Mapping[str, object]] = None, + fromlist: Sequence[str] = (), + level: int = 0, + ) -> ModuleType: """Override of the import method to track package names.""" m = CLASSIC_IMPORT(name, globals=globals, locals=locals, fromlist=fromlist, level=level) name = name.split(".")[0] @@ -50,7 +59,7 @@ def scooby_import(name, globals=None, locals=None, fromlist=(), level=0): return m -def track_imports(): +def track_imports() -> None: """Track all imported modules for the remainder of this session.""" if not TRACKING_SUPPORTED: raise RuntimeError(SUPPORT_MESSAGE) @@ -60,7 +69,7 @@ def track_imports(): return -def untrack_imports(): +def untrack_imports() -> None: """Stop tracking imports and return to the builtin import method. This will also clear the tracked imports. @@ -80,7 +89,13 @@ class TrackedReport(Report): ``globals()`` dictionary. """ - def __init__(self, additional=None, ncol=3, text_width=80, sort=False): + def __init__( + self, + additional: Optional[List[Union[str, ModuleType]]] = None, + ncol: int = 3, + text_width: int = 80, + sort: bool = False, + ): """Initialize.""" if not TRACKING_SUPPORTED: raise RuntimeError(SUPPORT_MESSAGE) diff --git a/setup.py b/setup.py index b76b8e3..1abec2e 100644 --- a/setup.py +++ b/setup.py @@ -38,4 +38,5 @@ "write_to": os.path.join("scooby", "version.py"), }, setup_requires=["setuptools_scm"], + package_data={"scooby": ["py.typed"]}, )