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