Skip to content
This repository has been archived by the owner on Sep 13, 2023. It is now read-only.

add requirements builder #434

Merged
merged 41 commits into from
Oct 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
cd51b90
add requirements builder
madhur-tandon Oct 7, 2022
05b9e5c
add builder for virtualenv
madhur-tandon Oct 7, 2022
6e6aea7
fix linting
madhur-tandon Oct 7, 2022
c747cc1
fix pylint again
madhur-tandon Oct 7, 2022
7654db9
fix entrypoints
madhur-tandon Oct 7, 2022
407203e
remove upgrade deps
madhur-tandon Oct 7, 2022
fc7c3dd
add ability to install in current active venv
madhur-tandon Oct 7, 2022
4446650
fix pylint
madhur-tandon Oct 7, 2022
df9e2a3
add ability to create conda based envs
madhur-tandon Oct 8, 2022
129ecdf
fix import
madhur-tandon Oct 8, 2022
50e4e30
fix has_conda check
madhur-tandon Oct 8, 2022
14a6626
fix windows issues
madhur-tandon Oct 8, 2022
7b6bc23
relax array comparison test
madhur-tandon Oct 8, 2022
5537ad3
fix typo
madhur-tandon Oct 9, 2022
b7c1d92
use conda in github actions
madhur-tandon Oct 9, 2022
1cb1d10
suggested improvements
madhur-tandon Oct 10, 2022
2171924
fix python version determination
madhur-tandon Oct 10, 2022
9161ab7
make create_virtual_env consistent
madhur-tandon Oct 11, 2022
39fcd57
suggested improvements
madhur-tandon Oct 12, 2022
a449918
add test for unix based req
madhur-tandon Oct 12, 2022
6239563
pass sample data
madhur-tandon Oct 12, 2022
896a25f
fix test
madhur-tandon Oct 12, 2022
e328ee8
minor improvements
madhur-tandon Oct 14, 2022
7d83350
use load_impl_ext for requirements builder
madhur-tandon Oct 14, 2022
031a8a4
fix tests
madhur-tandon Oct 14, 2022
b2141d4
add test for invalid req_type
madhur-tandon Oct 14, 2022
3080313
register conda mark in pytest
madhur-tandon Oct 14, 2022
550db69
add test for current and active venv
madhur-tandon Oct 14, 2022
7ccf8e1
fix tests
madhur-tandon Oct 14, 2022
993409b
fix test
madhur-tandon Oct 14, 2022
0d05981
add conda reqs as list for the builder
madhur-tandon Oct 14, 2022
4b798f2
remove usage of context and protected access
madhur-tandon Oct 14, 2022
6eea4ab
fix entrypoints
madhur-tandon Oct 14, 2022
bd72738
remove pylint disable
madhur-tandon Oct 14, 2022
4dabca5
move CondaPackageRequirement to mlem.contrib.venv
madhur-tandon Oct 17, 2022
6fa56ff
use materialize
madhur-tandon Oct 18, 2022
b5e968c
fix tests
madhur-tandon Oct 18, 2022
3d51c66
fix docstrings
madhur-tandon Oct 18, 2022
ecf555d
fix tests
madhur-tandon Oct 18, 2022
10e9e9c
fix docs based test
madhur-tandon Oct 18, 2022
0bd9007
suggested changes
madhur-tandon Oct 21, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/check-test-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}
- uses: conda-incubator/setup-miniconda@v2
with:
python-version: ${{ matrix.python }}
auto-activate-base: true
activate-environment: ""
- name: get pip cache dir
id: pip-cache-dir
run: |
Expand Down
52 changes: 52 additions & 0 deletions mlem/contrib/requirements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Requirements support
Extension type: build

MlemBuilder implementation for `Requirements` which includes
installable, conda, unix, custom, file etc. based requirements.
"""
import logging
from typing import ClassVar, Optional

from pydantic import validator

from mlem.core.base import load_impl_ext
from mlem.core.objects import MlemBuilder, MlemModel
from mlem.core.requirements import Requirement
from mlem.ui import EMOJI_OK, EMOJI_PACK, echo
from mlem.utils.entrypoints import list_implementations

REQUIREMENTS = "requirements.txt"

logger = logging.getLogger(__name__)


class RequirementsBuilder(MlemBuilder):
madhur-tandon marked this conversation as resolved.
Show resolved Hide resolved
"""MlemBuilder implementation for building requirements"""

type: ClassVar = "requirements"

target: Optional[str] = None
"""Target path for requirements"""
req_type: str = "installable"
"""Type of requirements, example: unix"""

@validator("req_type")
def get_req_type(cls, req_type): # pylint: disable=no-self-argument
if req_type not in list_implementations(Requirement):
raise ValueError(
f"req_type {req_type} is not valid. Allowed options are: {list_implementations(Requirement)}"
)
return req_type

def build(self, obj: MlemModel):
req_type_cls = load_impl_ext(Requirement.abs_name, self.req_type)
assert issubclass(req_type_cls, Requirement)
reqs = obj.requirements.of_type(req_type_cls)
if self.target is None:
reqs_representation = [r.get_repr() for r in reqs]
requirement_string = " ".join(reqs_representation)
print(requirement_string)
madhur-tandon marked this conversation as resolved.
Show resolved Hide resolved
else:
echo(EMOJI_PACK + "Materializing requirements...")
req_type_cls.materialize(reqs, self.target)
echo(EMOJI_OK + f"Materialized to {self.target}!")
203 changes: 203 additions & 0 deletions mlem/contrib/venv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
"""Virtual Environments support
Extension type: build

MlemBuilder implementations for `Environments` which includes
conda based and venv based virtual environments.
"""
import os
import platform
import subprocess
import sys
import venv
from abc import abstractmethod
from typing import ClassVar, List, Optional

from mlem.core.errors import MlemError
from mlem.core.objects import MlemBuilder, MlemModel
from mlem.core.requirements import Requirement
from mlem.ui import EMOJI_OK, EMOJI_PACK, echo


def get_python_exe_in_virtual_env(env_dir: str, use_conda_env: bool = False):
if platform.system() == "Windows":
if not use_conda_env:
return os.path.join(env_dir, "Scripts", "python.exe")
return os.path.join(env_dir, "python.exe")
return os.path.join(env_dir, "bin", "python")


def run_in_subprocess(cmd: List[str], error_msg: str, check_output=False):
try:
if check_output:
return subprocess.check_output(cmd)
return subprocess.run(cmd, check=True)
except (
FileNotFoundError,
subprocess.CalledProcessError,
subprocess.TimeoutExpired,
) as e:
raise MlemError(f"{error_msg}\n{e}") from e


class CondaPackageRequirement(Requirement):
"""Represents a conda package that needs to be installed"""

type: ClassVar[str] = "conda"
package_name: str
"""Denotes name of a package such as 'numpy'"""
spec: Optional[str] = None
"""Denotes selectors for a package such as '>=1.8,<2'"""
channel_name: str = "conda-forge"
"""Denotes channel from which a package is to be installed"""

def get_repr(self):
"""
conda installable representation of this module
"""
if self.spec is not None:
return f"{self.channel_name}::{self.package_name}{self.spec}"
return f"{self.channel_name}::{self.package_name}"

@classmethod
def materialize(cls, reqs, target: str):
raise NotImplementedError


class EnvBuilder(MlemBuilder):
type: ClassVar = "env"

target: Optional[str] = "venv"
"""Name of the virtual environment"""

@abstractmethod
def create_virtual_env(self):
mike0sv marked this conversation as resolved.
Show resolved Hide resolved
raise NotImplementedError

@abstractmethod
def get_installed_packages(self, env_dir: str):
raise NotImplementedError


class VenvBuilder(EnvBuilder):
"""MlemBuilder implementation for building virtual environments"""

type: ClassVar = "venv"

no_cache: bool = False
"""Disable cache"""
current_env: bool = False
"""Whether to install in the current virtual env, must be active"""

def create_virtual_env(self):
env_dir = os.path.abspath(self.target)
venv.create(env_dir, with_pip=True)

def get_installed_packages(self, env_dir):
env_exe = get_python_exe_in_virtual_env(env_dir)
return run_in_subprocess(
[env_exe, "-m", "pip", "freeze"],
error_msg="Error running pip",
check_output=True,
)

def build(self, obj: MlemModel):
if self.current_env:
if (
os.getenv("VIRTUAL_ENV") is None
or sys.prefix == sys.base_prefix
):
raise MlemError("No virtual environment detected.")
echo(EMOJI_PACK + f"Detected the virtual env {sys.prefix}")
env_dir = sys.prefix
else:
assert self.target is not None
echo(EMOJI_PACK + f"Creating virtual env {self.target}...")
self.create_virtual_env()
env_dir = os.path.abspath(self.target)
os.environ["VIRTUAL_ENV"] = env_dir

env_exe = get_python_exe_in_virtual_env(env_dir)
echo(EMOJI_PACK + "Installing the required packages...")
# Based on recommendation given in https://pip.pypa.io/en/latest/user_guide/#using-pip-from-your-program
install_cmd = [env_exe, "-m", "pip", "install"]
if self.no_cache:
install_cmd.append("--no-cache-dir")
install_cmd.extend(obj.requirements.to_pip())
run_in_subprocess(install_cmd, error_msg="Error running pip")
if platform.system() == "Windows":
activate_cmd = f"`{self.target}\\Scripts\\activate`"
else:
activate_cmd = f"`source {self.target}/bin/activate`"
echo(
EMOJI_OK
+ f"virtual environment `{self.target}` is ready, activate with {activate_cmd}"
)
return env_dir


class CondaBuilder(EnvBuilder):
"""MlemBuilder implementation for building conda environments"""

type: ClassVar = "conda"

python_version: str = f"{sys.version_info.major}.{sys.version_info.minor}"
"""The python version to use"""
current_env: Optional[bool] = False
"""Whether to install in the current conda env"""
mike0sv marked this conversation as resolved.
Show resolved Hide resolved
conda_reqs: List[CondaPackageRequirement] = []
"""List of conda package requirements"""

def create_virtual_env(self):
env_dir = os.path.abspath(self.target)
create_cmd = ["--prefix", env_dir, f"python={self.python_version}"]
run_in_subprocess(
["conda", "create", "-y", *create_cmd],
error_msg="Error running conda",
)

def get_installed_packages(self, env_dir):
return run_in_subprocess(
["conda", "list", "--prefix", env_dir],
error_msg="Error running conda",
check_output=True,
)

def build(self, obj: MlemModel):
pip_based_packages = obj.requirements.to_pip()
conda_based_packages = [r.get_repr() for r in self.conda_reqs]

if self.current_env:
conda_default_env = os.getenv("CONDA_DEFAULT_ENV", None)
if conda_default_env == "base" or conda_default_env is None:
raise MlemError("No conda environment detected.")
echo(EMOJI_PACK + f"Detected the conda env {sys.prefix}")
env_dir = sys.prefix
env_exe = sys.executable
else:
assert self.target is not None
self.create_virtual_env()
env_dir = os.path.abspath(self.target)
env_exe = get_python_exe_in_virtual_env(
env_dir, use_conda_env=True
)
if conda_based_packages:
run_in_subprocess(
[
"conda",
"install",
"--prefix",
env_dir,
"-y",
*conda_based_packages,
],
error_msg="Error running conda",
)

# install pip packages in conda env
if pip_based_packages:
run_in_subprocess(
[env_exe, "-m", "pip", "install", *pip_based_packages],
error_msg="Error running pip",
)

return env_dir
62 changes: 54 additions & 8 deletions mlem/core/requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,28 @@ class Config:
abs_name: ClassVar[str] = "requirement"
type: ClassVar = ...

@abstractmethod
def get_repr(self):
raise NotImplementedError

@classmethod
@abstractmethod
def materialize(cls, reqs, target: str):
raise NotImplementedError


class PythonRequirement(Requirement, ABC):
type: ClassVar = "_python"
module: str
"""Python module name"""

def get_repr(self):
raise NotImplementedError

@classmethod
def materialize(cls, reqs, target: str):
raise NotImplementedError


class InstallableRequirement(PythonRequirement):
"""
Expand All @@ -85,14 +101,21 @@ def package(self):
self.module, self.module
)

def to_str(self):
def get_repr(self):
"""
pip installable representation of this module
"""
if self.version is not None:
return f"{self.package}=={self.version}"
return self.package

@classmethod
def materialize(cls, reqs, target: str):
reqs = [r.get_repr() for r in reqs]
requirement_string = "\n".join(reqs)
with open(os.path.join(target), "w", encoding="utf8") as fp:
fp.write(requirement_string + "\n")

@classmethod
def from_module(
cls, mod: ModuleType, package_name: str = None
Expand Down Expand Up @@ -148,6 +171,18 @@ class CustomRequirement(PythonRequirement):
is_package: bool
"""Whether this code should be in %name%/__init__.py"""

def get_repr(self):
raise NotImplementedError

@classmethod
def materialize(cls, reqs, target: str):
for cr in reqs:
for part, src in cr.to_sources_dict().items():
p = os.path.join(target, part)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "wb") as f:
f.write(src)

@staticmethod
def from_module(mod: ModuleType) -> "CustomRequirement":
"""
Expand Down Expand Up @@ -273,6 +308,9 @@ class FileRequirement(CustomRequirement):
module: str = ""
"""Ignored"""

def get_repr(self):
raise NotImplementedError

def to_sources_dict(self):
"""
Mapping path -> source code for this requirement
Expand All @@ -296,6 +334,13 @@ class UnixPackageRequirement(Requirement):
package_name: str
"""Name of the package"""

def get_repr(self):
return self.package_name

@classmethod
def materialize(cls, reqs, target: str):
raise NotImplementedError


T = TypeVar("T", bound=Requirement)

Expand Down Expand Up @@ -399,11 +444,17 @@ def add(self, requirement: Requirement):
if requirement not in self.__root__:
self.__root__.append(requirement)

def to_unix(self) -> List[str]:
"""
:return: list of unix based packages
"""
return [r.get_repr() for r in self.of_type(UnixPackageRequirement)]

def to_pip(self) -> List[str]:
"""
:return: list of pip installable packages
"""
return [r.to_str() for r in self.installable]
return [r.get_repr() for r in self.installable]

def __add__(self, other: "AnyRequirements"):
other = resolve_requirements(other)
Expand All @@ -426,12 +477,7 @@ def new(cls, requirements: "AnyRequirements" = None):
return resolve_requirements(requirements)

def materialize_custom(self, path: str):
madhur-tandon marked this conversation as resolved.
Show resolved Hide resolved
for cr in self.custom:
for part, src in cr.to_sources_dict().items():
p = os.path.join(path, part)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "wb") as f:
f.write(src)
CustomRequirement.materialize(self.custom, path)

@contextlib.contextmanager
def import_custom(self):
Expand Down
Loading