diff --git a/README.md b/README.md index 25cdb96..a86f282 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,43 @@ installed, it will raise the following exception: `conda install -c conda-forge scooby`. ``` +### Autogenerate Reports for any Packages + +Scooby can automatically generate a Report for any package and its +distribution requirements with the `AutoReport` class: + +```py +>>> import scooby +>>> scooby.AutoReport('matplotlib') +``` +``` +-------------------------------------------------------------------------------- + Date: Fri Oct 20 16:49:34 2023 PDT + + OS : Darwin + CPU(s) : 8 + Machine : arm64 + Architecture : 64bit + RAM : 16.0 GiB + Environment : Python + File system : apfs + + Python 3.11.3 | packaged by conda-forge | (main, Apr 6 2023, 08:58:31) + [Clang 14.0.6 ] + + matplotlib : 3.7.1 + contourpy : 1.0.7 + cycler : 0.11.0 + fonttools : 4.39.4 + kiwisolver : 1.4.4 + numpy : 1.24.3 + packaging : 23.1 + pillow : 9.5.0 + pyparsing : 3.0.9 + python-dateutil : 2.8.2 +-------------------------------------------------------------------------------- +``` + ### Solving Mysteries Are you struggling with the mystery of whether or not code is being executed in @@ -363,16 +400,51 @@ Scooby comes with a command-line interface. Simply typing scooby ``` -in a terminal will display the default report. You can also use it to show the -scooby-report of another package, if that package has scooby implemented as -suggested above, using `packagename.Report()`. As an example, to print the -report of pyvista you can run +in a terminal will display the default report. You can also use the CLI to show +the scooby Report of another package if that package has implemented a Report +class as suggested above, using `packagename.Report()`. + +As an example, to print the report of pyvista you can run ```bash -scooby --report pyvista +scooby -r pyvista ``` -which will show the report of PyVista. +which will show the Report implemented in PyVista. + +The CLI can also generate a report based on the dependencies of a package's +distribution where that package hasn't implemented a Report class. For example, +we can generate a Report for `matplotlib` and its dependencies: + +```bash +$ scooby -r matplotlib +-------------------------------------------------------------------------------- + Date: Fri Oct 20 17:03:45 2023 PDT + + OS : Darwin + CPU(s) : 8 + Machine : arm64 + Architecture : 64bit + RAM : 16.0 GiB + Environment : Python + File system : apfs + + Python 3.11.3 | packaged by conda-forge | (main, Apr 6 2023, 08:58:31) + [Clang 14.0.6 ] + + matplotlib : 3.7.1 + contourpy : 1.0.7 + cycler : 0.11.0 + fonttools : 4.39.4 + kiwisolver : 1.4.4 + numpy : 1.24.3 + packaging : 23.1 + pillow : 9.5.0 + pyparsing : 3.0.9 + python-dateutil : 2.8.2 +importlib-resources : 5.12.0 +-------------------------------------------------------------------------------- +``` Simply type @@ -382,7 +454,6 @@ scooby --help to see all the possibilities. - ## Optional Requirements The following is a list of optional requirements and their purpose: diff --git a/scooby/__init__.py b/scooby/__init__.py index 4120216..52bf6a0 100644 --- a/scooby/__init__.py +++ b/scooby/__init__.py @@ -22,12 +22,13 @@ meets_version, version_tuple, ) -from scooby.report import Report, get_version +from scooby.report import AutoReport, Report, get_version from scooby.tracker import TrackedReport, track_imports, untrack_imports doo = Report __all__ = [ + 'AutoReport', 'Report', 'TrackedReport', 'doo', diff --git a/scooby/__main__.py b/scooby/__main__.py index ec56dea..62c33aa 100644 --- a/scooby/__main__.py +++ b/scooby/__main__.py @@ -1,11 +1,12 @@ """Create entry point for the command-line interface (CLI).""" import argparse import importlib +from importlib.metadata import PackageNotFoundError import sys from typing import Any, Dict, List, Optional import scooby -from scooby.report import Report +from scooby.report import Report, get_distribution_dependencies def main(args: Optional[List[str]] = None): @@ -24,15 +25,15 @@ def main(args: Optional[List[str]] = None): # arg: Report of a package parser.add_argument( - "--report", default=None, type=str, help=("print `Report()` of this package") + "--report", "-r", default=None, type=str, help=("print `Report()` of this package") ) # arg: Sort parser.add_argument( "--no-opt", action="store_true", - default=False, - help="do not show the default optional packages", + default=None, + help="do not show the default optional packages. Defaults to True if using --report and defaults to False otherwise.", ) # arg: Sort @@ -45,7 +46,7 @@ def main(args: Optional[List[str]] = None): # arg: Version parser.add_argument( - "--version", action="store_true", default=False, help="only display scooby version" + "--version", "-v", action="store_true", default=False, help="only display scooby version" ) # Call act with command line arguments as dict. @@ -59,32 +60,50 @@ def act(args_dict: Dict[str, Any]) -> None: print(f"scooby v{scooby.__version__}") return - # Report of another package. report = args_dict.pop('report') + no_opt = args_dict.pop('no_opt') + packages = args_dict.pop('packages') + + if no_opt is None: + if report is None: + no_opt = False + else: + no_opt = True + + # Report of another package. if report: try: module = importlib.import_module(report) except ImportError: print(f"Package `{report}` could not be imported.", file=sys.stderr) - return + sys.exit(1) try: print(module.Report()) + return except AttributeError: - print(f"Package `{report}` has no attribute `Report()`.", file=sys.stderr) + pass + + try: + dist_deps = get_distribution_dependencies(report) + packages = [report, *dist_deps, *packages] + except PackageNotFoundError: + print( + f"Package `{report}` has no Report class and `importlib` could not be used to autogenerate one.", + file=sys.stderr, + ) + sys.exit(1) - # Scooby report with additional options. - else: - # Collect input. - inp = {'additional': args_dict['packages'], 'sort': args_dict['sort']} + # Collect input. + inp = {'additional': packages, 'sort': args_dict['sort']} - # Define optional as empty list if no-opt. - if args_dict['no_opt']: - inp['optional'] = [] + # Define optional as empty list if no-opt. + if no_opt: + inp['optional'] = [] - # Print the report. - print(Report(**inp)) + # Print the report. + print(Report(**inp)) if __name__ == "__main__": - sys.exit(main()) + main() diff --git a/scooby/report.py b/scooby/report.py index b01a4e1..a78f119 100644 --- a/scooby/report.py +++ b/scooby/report.py @@ -1,7 +1,7 @@ """The main module containing the `Report` class.""" import importlib -from importlib.metadata import PackageNotFoundError, version as importlib_version +from importlib.metadata import PackageNotFoundError, distribution, version as importlib_version import sys import time from types import ModuleType @@ -434,6 +434,41 @@ def to_dict(self) -> Dict[str, str]: return out +class AutoReport(Report): + """Auto-generate a scooby.Report for a package. + + This will check if the specified package has a ``Report`` class and use that or + fallback to generating a report based on the distribution requirements of the package. + """ + + def __init__(self, module, additional=None, ncol=3, text_width=80, sort=False): + """Initialize.""" + if not isinstance(module, (str, ModuleType)): + raise TypeError("Cannot generate report for type " "({})".format(type(module))) + + if isinstance(module, ModuleType): + module = module.__name__ + + try: + package = importlib.import_module(module) + if issubclass(package.Report, Report): + package.Report.__init__( + self, additional=additional, ncol=ncol, text_width=text_width, sort=sort + ) + except (AttributeError, ImportError): + # Autogenerate from distribution requirements + core = [module, *get_distribution_dependencies(module)] + Report.__init__( + self, + additional=additional, + core=core, + optional=[], + ncol=ncol, + text_width=text_width, + sort=sort, + ) + + # This functionaliy might also be of interest on its own. def get_version(module: Union[str, ModuleType]) -> Tuple[str, Optional[str]]: """Get the version of ``module`` by passing the package or it's name. @@ -512,3 +547,23 @@ def platform() -> ModuleType: import platform return platform + + +def get_distribution_dependencies(dist_name: str): + """Get the dependencies of a specified package distribution. + + Parameters + ---------- + dist_name : str + Name of the package distribution. + + Returns + ------- + dependencies : list + List of dependency names. + """ + try: + dist = distribution(dist_name) + except PackageNotFoundError: + raise PackageNotFoundError(f"Package `{dist_name}` has no distribution.") + return [pkg.split()[0] for pkg in dist.requires] diff --git a/tests/test_scooby.py b/tests/test_scooby.py index dfb7805..46d9840 100644 --- a/tests/test_scooby.py +++ b/tests/test_scooby.py @@ -252,3 +252,25 @@ def rep_comp(inp): ret = script_runner.run(['python', os.path.join('scooby', '__main__.py'), '--version']) assert ret.success assert "scooby v" in ret.stdout + + # default: scooby-Report for matplotlibe + ret = script_runner.run(['scooby', '--report', 'pytest']) + assert ret.success + assert "pytest" in ret.stdout + assert "iniconfig" in ret.stdout + + # handle error -- no distribution + ret = script_runner.run(['scooby', '--report', 'pathlib']) + assert not ret.success + assert "importlib" in ret.stderr + + # handle error -- not found + ret = script_runner.run(['scooby', '--report', 'foobar']) + assert not ret.success + assert "could not be imported" in ret.stderr + + +def test_auto_report(): + report = scooby.AutoReport('pytest') + assert 'pytest' in report.packages + assert 'iniconfig' in report.packages