Skip to content

Commit

Permalink
Bump version, 0.2 -> 0.3. A significant refactoring, but similar API
Browse files Browse the repository at this point in the history
  • Loading branch information
t-silvers committed Jul 18, 2023
1 parent 131864b commit f921714
Show file tree
Hide file tree
Showing 34 changed files with 1,615 additions and 985 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ __pycache__/*
.vscode
tags
_dev
tests/envs/test-env
usr/envs/*
usr/scripts/*
*.db
Expand Down
235 changes: 152 additions & 83 deletions README.rst

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@
- Clean up signatures for `PyRFunc` funcs that return instances. Tackle after refactoring env creation.
- Log env and script creation
- Use `Arrow` for big-data "`df"-capturing
- Reduce `subprocess` calls for apply-style functions
6 changes: 3 additions & 3 deletions src/pyrty/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

from pyrty.pyr_env import PyREnv
from pyrty.pyr_func import PyRFunc
from pyrty.pyr_rscript import PyRScript
from pyrty.registry import DBManager, RegistryManager
from pyrty.pyr_script import PyRScript
from pyrty.registry import DBManager, RegistryManager, unregister_pyrty_func

__all__ = ['PyREnv', 'PyRScript', 'PyRFunc', 'DBManager', 'RegistryManager']
__all__ = ['PyREnv', 'PyRScript', 'PyRFunc', 'DBManager', 'RegistryManager', 'unregister_pyrty_func']
5 changes: 5 additions & 0 deletions src/pyrty/cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from enum import Enum

class Cleanup(Enum):
ENV = 'env'
RSCRIPT = 'rscript'
23 changes: 23 additions & 0 deletions src/pyrty/env_managers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from pyrty.env_managers.base_env import BaseEnvManager
from pyrty.env_managers.conda import CondaEnvManager, MambaEnvManager
# from pyrty.env_managers.packrat import PackratEnvCreator
# from pyrty.env_managers.renv import RenvEnvCreator
# from pyrty.env_managers.venv import VenvEnvCreator

_env_managers = {
'conda': CondaEnvManager,
'mamba': MambaEnvManager,
# 'packrat': PackratEnvCreator,
# 'renv': RenvEnvCreator,
# 'venv': VenvEnvCreator,
}

__all__ = [
'_env_creators',
'BaseEnvManager',
'CondaEnvManager',
'MambaEnvManager',
# 'PackratEnvCreator',
# 'RenvEnvCreator',
# 'VenvEnvCreator',
]
78 changes: 78 additions & 0 deletions src/pyrty/env_managers/base_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import subprocess
from abc import ABC, abstractmethod
from pathlib import Path

from pyrty.env_managers.utils import SHELL_EXE


class BaseEnvManager(ABC):

def __str__(self) -> str:
return super().__str__()

def get_run_cmd(self, cmd: str) -> str:
return self.run_cmd_template.format(cmd=cmd)

def create(self) -> None:
subprocess.run([SHELL_EXE, str(self.deploy_script_path)], check=True)
if self.postdeploy_script_path.exists():
subprocess.run([SHELL_EXE, str(self.postdeploy_script_path)], check=True)

def remove(self) -> None:
if self.exists:
subprocess.run(self.remove_cmd, check=True, shell=True)

# TODO: Something strange happening here on unregistering
if self.deploy_script_path.exists():
self.deploy_script_path.unlink()

if self.postdeploy_script_path.exists():
self.postdeploy_script_path.unlink()
else:
raise FileNotFoundError(f'Environment {self.prefix} does not exist.')

@property
@abstractmethod
def deploy_script_path(self):
pass

@property
@abstractmethod
def env(self):
pass

@property
@abstractmethod
def name(self):
pass

@property
@abstractmethod
def postdeploy_script_path(self):
pass

@property
@abstractmethod
def prefix(self):
pass

@property
@abstractmethod
def remove_cmd(self):
pass

@property
@abstractmethod
def run_cmd_template(self):
pass

@property
def exe(self) -> str:
return self._exe

@property
def exists(self) -> bool:
if self.prefix:
return Path(self.prefix).is_dir()
else:
return False
247 changes: 247 additions & 0 deletions src/pyrty/env_managers/conda.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import logging
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path

import yaml

from pyrty.env_managers.base_env import BaseEnvManager
from pyrty.env_managers.utils import (
DEFAULT_CHANNELS,
default_env_name,
default_env_prefix,
write_conda_deploy_script,
)

_logger = logging.getLogger(__name__)


@dataclass
class CondaEnv:
"""Represents a Conda environment."""

name: str
dependencies: list
channels: list = field(default_factory=lambda: DEFAULT_CHANNELS)

def __str__(self):
return self.to_yaml()

def to_yaml(self) -> str:
"""Convert the CondaEnv object to a YAML string.
Returns:
str: The YAML representation of the object.
"""
return yaml.safe_dump(self.__dict__, default_flow_style=False)

def write(self, path: str) -> None:
"""Write the CondaEnv object to a YAML file.
Args:
path (str): The path of the file to write to.
"""
yaml_string = self.to_yaml()
with open(path, 'w') as file:
file.write(yaml_string)

@classmethod
def from_yaml(cls, path: str):
"""Create a CondaEnv instance from a YAML file.
Args:
path (str): The path of the YAML file.
Returns:
CondaEnv: The created CondaEnv instance.
"""
with open(path, 'r') as file:
yaml_data = yaml.safe_load(file)

return cls(yaml_data.get('name', ''),
yaml_data.get('dependencies', []),
yaml_data.get('channels', []))

@classmethod
def from_existing(cls, exe: Path, prefix: Path, path: Path):
"""Create a CondaEnv instance from an existing Conda env.
Args:
prefix (str): The path of the Conda env.
Returns:
CondaEnv: The created CondaEnv instance.
"""
subprocess.run(f'{str(exe)} run -p {str(prefix)} {str(exe)} env export > {str(path)}', shell=True, check=True)
return cls.from_yaml(path)

@property
def r_packages(self) -> list:
"""List of R packages (including CRAN and Bioconductor) in the environment.
Returns:
list: List of R packages.
"""
return self.cran_packages + self.bioc_packages

@property
def cran_packages(self) -> list:
"""List of CRAN packages in the environment.
Returns:
list: List of CRAN packages.
"""
return [dep for dep in self.dependencies if dep.startswith('r-')]

@property
def bioc_packages(self) -> list:
"""List of Bioconductor packages in the environment.
Returns:
list: List of Bioconductor packages.
"""
return [dep for dep in self.dependencies if dep.startswith('bioconductor-')]


class CondaEnvManager(BaseEnvManager):
def __init__(
self,
exe: Path = None,
prefix: Path = None,
envfile: Path = None,
name: str = None,
dependencies: list = None,
channels: list = None,
postdeploy_cmds: list[str] = None,
):
self._exe = exe if exe else shutil.which("conda")
self._prefix = prefix
self.envfile = envfile
self._name = name
self._dependencies = dependencies
self.channels = channels or DEFAULT_CHANNELS
self.postdeploy_cmds = postdeploy_cmds

def create(self):
# Overwrite
self._process_env_specs(self.prefix, self.envfile, self.name,
self.dependencies, self.channels)
self._write_deploy_script()
self._process_postdeploy_cmds(self.postdeploy_cmds)
super().create()

def _process_env_specs(self, prefix, envfile, name, dependencies, channels):
if not envfile and name:
envfile = Path(f"{name}.yaml")
elif not envfile and not name:
raise ValueError("Must provide either a path (to `envfile`) or name.")
self.envfile = Path(envfile)

# -- When environment already exists ...
self._prefix = prefix # May be None
if self.exists:
_logger.info(f"Environment exists at {self.prefix}.")
self._env = CondaEnv.from_existing(self.exe, self.prefix, self.envfile)

else:
if not self.prefix: # Now must be provided or generated
if name:
self._prefix = default_env_prefix(name)
elif not name and envfile:
# Parse name from envfile
with open(self.envfile, 'r') as file:
yaml_data = yaml.safe_load(file)
name = yaml_data['name'] # Should error if name not in yaml_data
self._prefix = name
else:
raise ValueError("Must provide either an envfile or name and dependencies.")

# -- When environment file exists ...
if self.envfile.exists():
_logger.info(f"Environment file {self.envfile} exists.")
self._env = CondaEnv.from_yaml(self.envfile)

# -- When environment dependencies are provided ...
elif name and dependencies:
name_ = default_env_name(name)
_logger.info(f"Creating environment {name_} with dependencies {dependencies}.")
self._env = CondaEnv(name_, dependencies, channels)
self._env.write(self.envfile)

# -- Else ...
else:
raise ValueError("Must provide either an envfile or name and dependencies.")

self._name = self.env.name

def add_channel(self, channel) -> None:
if channel not in self.channels:
self.channels.append(channel)

# def add_dependency(self, dependency) -> None:
# if dependency not in self.dependencies:
# self.dependencies.append(dependency)

@property
def deploy_script_path(self) -> Path:
return Path(self.prefix).parent / f"{self.name}.deploy.sh"

@property
def dependencies(self):
return self._dependencies

@dependencies.setter
def add_dependency(self, dependency):
if dependency not in self._dependencies:
self._dependencies.append(dependency)

@property
def env(self):
return self._env

@property
def name(self):
return self._name

@property
def postdeploy_script_path(self) -> Path:
return Path(self.prefix).parent / f"{self.name}.post-deploy.sh"

@property
def prefix(self):
return self._prefix

@property
def remove_cmd(self):
return f"{self.exe} env remove --prefix {self.prefix} -y"

@property
def run_cmd_template(self) -> str:
return f"{self.exe} run -p {self.prefix} {{cmd}}"

def _write_deploy_script(self) -> None:
write_conda_deploy_script(self.deploy_script_path, self.exe, self.prefix, self.envfile)

def _process_postdeploy_cmds(self, postdeploy_cmds):
if postdeploy_cmds:
self._write_postdeploy_script(postdeploy_cmds)

def _write_postdeploy_script(self, cmds: list[str]) -> None:
postdeploy_commands = "\n".join([self.get_run_cmd(cmd) for cmd in cmds])
self.postdeploy_script_path.write_text(postdeploy_commands)

class MambaEnvManager(CondaEnvManager):
def __init__(
self,
exe: Path = None,
prefix: Path = None,
envfile: Path = None,
name: str = None,
dependencies: list = None,
channels: list = None,
postdeploy_cmds: list[str] = None,
):
super().__init__(exe=exe or shutil.which("mamba"), prefix=prefix,
envfile=envfile, name=name, dependencies=dependencies,
channels=channels, postdeploy_cmds=postdeploy_cmds)
Loading

0 comments on commit f921714

Please sign in to comment.