Skip to content

Commit

Permalink
Autogenerate Reports for any package and its dependencies (#118)
Browse files Browse the repository at this point in the history
* Autogenerate reports CLI

* pkg_resources is optional fallback

* More tests

* -r alias

* Fix test

* Improvements

* Improve README

* Typos

* Create AutoReport class

* Use importlib

* Fix output

* Fix formatting

* Typo

* Improve errors
  • Loading branch information
banesullivan authored Oct 21, 2023
1 parent a5a34b0 commit 301a855
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 27 deletions.
85 changes: 78 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -382,7 +454,6 @@ scooby --help

to see all the possibilities.


## Optional Requirements

The following is a list of optional requirements and their purpose:
Expand Down
3 changes: 2 additions & 1 deletion scooby/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
55 changes: 37 additions & 18 deletions scooby/__main__.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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()
57 changes: 56 additions & 1 deletion scooby/report.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
22 changes: 22 additions & 0 deletions tests/test_scooby.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 301a855

Please sign in to comment.