metaflow/cmd/develop/stubs.py (258 lines of code) (raw):

import importlib import os import subprocess import sys import tempfile from typing import Any, List, Optional, Tuple from metaflow._vendor import click from . import develop from .stub_generator import StubGenerator _py_ver = sys.version_info[:2] _metadata_package = None def _check_stubs_supported(): global _metadata_package if _metadata_package is not None: return _metadata_package else: if _py_ver >= (3, 4): if _py_ver >= (3, 8): from importlib import metadata elif _py_ver >= (3, 7): from metaflow._vendor.v3_7 import importlib_metadata as metadata elif _py_ver >= (3, 6): from metaflow._vendor.v3_6 import importlib_metadata as metadata else: from metaflow._vendor.v3_5 import importlib_metadata as metadata _metadata_package = metadata return _metadata_package @develop.group(short_help="Stubs management") @click.pass_context def stubs(ctx: Any): """ Stubs provide type hints and documentation hints to IDEs and are typically provided inline with the code where a static analyzer can pick them up. In Metaflow's case, however, proper stubs rely on dynamic behavior (ie: the decorators are generated at runtime). This makes it necessary to have separate stub files. This CLI provides utilities to check and generate stubs for your current Metaflow installation. """ if _check_stubs_supported() is None: raise click.UsageError( "Building and installing stubs are not supported on Python %d.%d " "(3.4 minimum required)" % _py_ver, ctx=ctx, ) @stubs.command(short_help="Check validity of stubs") @click.pass_context def check(ctx: Any): """ Checks the currently installed stubs (if they exist) and validates that they match the currently installed version of Metaflow. """ dist_packages, paths = get_packages_for_stubs() if len(dist_packages) + len(paths) == 0: return print_status(ctx, "no package provides `metaflow-stubs`", False) if len(dist_packages) + len(paths) == 1: if dist_packages: return print_status( ctx, *internal_check(dist_packages[0][1], dist_packages[0][0]) ) return print_status(ctx, *internal_check(paths[0])) pkg_names = None pkg_paths = None if dist_packages: pkg_names = " packages " + ", ".join([p[0] for p in dist_packages]) if paths: pkg_paths = "directories at " + ", ".join(paths) return print_status( ctx, "metaflow-stubs is provided multiple times by%s %s%s" % ( pkg_names if pkg_names else "", "and " if pkg_names and pkg_paths else "", pkg_paths if pkg_paths else "", ), False, ) @stubs.command(short_help="Remove all packages providing metaflow stubs") @click.pass_context def remove(ctx: Any): """ Removes all packages that provide metaflow-stubs from the current Python environment. """ dist_packages, paths = get_packages_for_stubs() if len(dist_packages) + len(paths) == 0: if ctx.obj.quiet: ctx.obj.echo_always("not_installed") else: ctx.obj.echo("No packages provide `metaflow-stubs") if paths: raise RuntimeError( "Cannot remove stubs when metaflow-stubs is already provided by a directory. " "Please remove the following and try again: %s" % ", ".join(paths) ) pkgs_to_remove = [p[0] for p in dist_packages] ctx.obj.echo( "Uninstalling existing packages providing metaflow-stubs: %s" % ", ".join(pkgs_to_remove) ) subprocess.check_call( [ sys.executable, "-m", "pip", "uninstall", "-y", *pkgs_to_remove, ], stderr=subprocess.DEVNULL if ctx.obj.quiet else None, stdout=subprocess.DEVNULL if ctx.obj.quiet else None, ) if ctx.obj.quiet: ctx.obj.echo_always("ok") else: ctx.obj.echo("All packages providing metaflow-stubs have been removed.") @stubs.command(short_help="Generate Python stubs") @click.pass_context @click.option( "--force/--no-force", default=False, show_default=True, help="Force installation of stubs even if they exist and are valid", ) def install(ctx: Any, force: bool): """ Generates the Python stubs for Metaflow considering the installed version of Metaflow. The stubs will be generated if they do not exist or do not match the current version of Metaflow and installed in the Python environment. """ try: import build except ImportError: raise RuntimeError( "Installing stubs requires 'build' -- please install it and try again" ) dist_packages, paths = get_packages_for_stubs() if paths: raise RuntimeError( "Cannot install stubs when metaflow-stubs is already provided by a directory. " "Please remove the following and try again: %s" % ", ".join(paths) ) if len(dist_packages) == 1: if internal_check(dist_packages[0][1])[1] == True and not force: if ctx.obj.quiet: ctx.obj.echo_always("already_installed") else: ctx.obj.echo( "Metaflow stubs are already installed and valid -- use --force to reinstall" ) return mf_version, _ = get_mf_version(True) with tempfile.TemporaryDirectory() as tmp_dir: with open(os.path.join(tmp_dir, "setup.py"), "w") as f: f.write( f""" from setuptools import setup, find_namespace_packages setup( include_package_data=True, name="metaflow-stubs", version="{mf_version}", description="Metaflow: More Data Science, Less Engineering", author="Metaflow Developers", author_email="help@metaflow.org", license="Apache Software License", packages=find_namespace_packages(), package_data={{"metaflow-stubs": ["generated_for.txt", "py.typed", "**/*.pyi"]}}, install_requires=["metaflow=={mf_version}"], python_requires=">=3.5.2", ) """ ) with open(os.path.join(tmp_dir, "MANIFEST.in"), "w") as f: f.write( """ include metaflow-stubs/generated_for.txt include metaflow-stubs/py.typed global-include *.pyi """ ) StubGenerator(os.path.join(tmp_dir, "metaflow-stubs")).write_out() subprocess.check_call( [sys.executable, "-m", "build", "--wheel"], cwd=tmp_dir, stderr=subprocess.DEVNULL if ctx.obj.quiet else None, stdout=subprocess.DEVNULL if ctx.obj.quiet else None, ) if dist_packages: # We need to uninstall all the other packages first pkgs_to_remove = [p[0] for p in dist_packages] ctx.obj.echo( "Uninstalling existing packages providing metaflow-stubs: %s" % ", ".join(pkgs_to_remove) ) subprocess.check_call( [ sys.executable, "-m", "pip", "uninstall", "-y", *pkgs_to_remove, ], cwd=tmp_dir, stderr=subprocess.DEVNULL if ctx.obj.quiet else None, stdout=subprocess.DEVNULL if ctx.obj.quiet else None, ) subprocess.check_call( [ sys.executable, "-m", "pip", "install", "--force-reinstall", "--no-deps", "--no-index", "--find-links", os.path.join(tmp_dir, "dist"), "metaflow-stubs", ], cwd=tmp_dir, stderr=subprocess.DEVNULL if ctx.obj.quiet else None, stdout=subprocess.DEVNULL if ctx.obj.quiet else None, ) if ctx.obj.quiet: ctx.obj.echo_always("installed") else: ctx.obj.echo("Metaflow stubs successfully installed") def split_version(vers: str) -> Tuple[str, Optional[str]]: vers_split = vers.split("+", 1) if len(vers_split) == 1: return vers_split[0], None return vers_split[0], vers_split[1] def get_mf_version(public: bool = False) -> Tuple[str, Optional[str]]: from metaflow.metaflow_version import get_version return split_version(get_version(public)) def get_stubs_version(stubs_root_path: Optional[str]) -> Tuple[str, Optional[str]]: if stubs_root_path is None: # The stubs are NOT an integrated part of metaflow return None, None if not os.path.isfile(os.path.join(stubs_root_path, "generated_for.txt")): return None, None with open( os.path.join(stubs_root_path, "generated_for.txt"), "r", encoding="utf-8" ) as f: return split_version(f.read().strip().split(" ", 1)[0]) def internal_check(stubs_path: str, pkg_name: Optional[str] = None) -> Tuple[str, bool]: mf_version = get_mf_version() stub_version = get_stubs_version(stubs_path) if stub_version == (None, None): return "the installed stubs package does not seem valid", False elif stub_version != mf_version: return ( "the stubs package was generated for Metaflow version %s%s " "but you have Metaflow version %s%s installed." % ( stub_version[0], " and extensions %s" % stub_version[1] if stub_version[1] else "", mf_version[0], " and extensions %s" % mf_version[1] if mf_version[1] else "", ), False, ) return ( "the stubs package %s matches your current Metaflow version" % (pkg_name if pkg_name else "installed at '%s'" % stubs_path), True, ) def get_packages_for_stubs() -> Tuple[List[Tuple[str, str]], List[str]]: """ Gets the packages that provide metaflow-stubs. This returns two lists: - the first list contains tuples of package names and root path for the package - the second list contains all non package names (ie: things in path for example) Returns ------- Tuple[List[Tuple[str, str]], Optional[List[Tuple[str, str]]]] Packages or paths providing metaflow-stubs """ try: m = importlib.import_module("metaflow-stubs") all_paths = set(m.__path__) except: return [], [] dist_list = [] # We check the type because if the user has multiple importlib metadata, for # some reason it shows up multiple times. interesting_dists = [ d for d in _metadata_package.distributions() if any( [ p == "metaflow-stubs" for p in (d.read_text("top_level.txt") or "").split() ] ) and isinstance(d, _metadata_package.PathDistribution) ] for dist in interesting_dists: # This is a package we care about root_path = dist.locate_file("metaflow-stubs").as_posix() dist_list.append((dist.metadata["Name"], root_path)) all_paths.discard(root_path) return dist_list, list(all_paths) def print_status(ctx: click.Context, msg: str, valid: bool): if ctx.obj.quiet: ctx.obj.echo_always("valid" if valid else "invalid") else: ctx.obj.echo("Metaflow stubs are ", nl=False) if valid: ctx.obj.echo("valid", fg="green", nl=False) else: ctx.obj.echo("invalid", fg="red", nl=False) ctx.obj.echo(": " + msg) return