Skip to content

Commit

Permalink
Merge pull request #53 – Pipenv
Browse files Browse the repository at this point in the history
  • Loading branch information
encukou committed Aug 25, 2018
2 parents 9b17864 + 6954af4 commit d7c9a60
Show file tree
Hide file tree
Showing 22 changed files with 435 additions and 536 deletions.
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Requirements

Requirements for certain backends:

* `Pipenv <https://docs.pipenv.org/>`_ (for certain usecases in `Virtualenv Backend <https://arca.readthedocs.io/en/latest/backends.html#virtual-environment>`_)
* `Docker <https://www.docker.com/>`_ (for `Docker Backend <https://arca.readthedocs.io/en/latest/backends.html#docker>`_
and `Vagrant Backend <https://arca.readthedocs.io/en/latest/backends.html#vagrant>`_)
* `Vagrant <https://www.vagrantup.com/>`_ (for the `Vagrant Backend <https://arca.readthedocs.io/en/latest/backends.html#vagrant>`_)
Expand Down
7 changes: 3 additions & 4 deletions arca/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from ._arca import Arca
from .backend import BaseBackend, VenvBackend, DockerBackend, CurrentEnvironmentBackend, RequirementsStrategy, \
VagrantBackend
from .backend import BaseBackend, VenvBackend, DockerBackend, CurrentEnvironmentBackend, VagrantBackend
from .result import Result
from .task import Task


__all__ = ["Arca", "BaseBackend", "VenvBackend", "DockerBackend", "Result", "Task", "CurrentEnvironmentBackend",
"RequirementsStrategy", "VagrantBackend"]
__version__ = "0.2.1"
"VagrantBackend"]
__version__ = "0.3.0"
5 changes: 2 additions & 3 deletions arca/backend/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
from .base import BaseBackend
from .venv import VenvBackend
from .docker import DockerBackend
from .current_environment import CurrentEnvironmentBackend, RequirementsStrategy
from .current_environment import CurrentEnvironmentBackend
from .vagrant import VagrantBackend


__all__ = ["BaseBackend", "VenvBackend", "DockerBackend", "CurrentEnvironmentBackend", "RequirementsStrategy",
"VagrantBackend"]
__all__ = ["BaseBackend", "VenvBackend", "DockerBackend", "CurrentEnvironmentBackend", "VagrantBackend"]
61 changes: 45 additions & 16 deletions arca/backend/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import hashlib
import re
import subprocess
from enum import Enum
from pathlib import Path
from typing import Optional, Tuple

Expand All @@ -14,15 +15,24 @@
from arca.utils import NOT_SET, LazySettingProperty, logger


class RequirementsOptions(Enum):
pipfile = 1
requirements_txt = 2
no_requirements = 3


class BaseBackend:
""" Abstract class for all the backends, implements some basic functionality.
Available settings:
* **requirements_location**: Relative path to the requirements file in the target repositories.
(default is ``requirements.txt``)
Setting to ``None`` makes Arca ignore requirements. (default is ``requirements.txt``)
* **requirements_timeout**: The maximum time in seconds allowed for installing requirements.
(default is 5 minutes, 300 seconds)
* **pipfile_location**: The folder containing ``Pipfile`` and ``Pipfile.lock``. Pipenv files take precedence
over requirements files. Setting to ``None`` makes Arca ignore Pipenv files.
(default is the root of the repository)
* **cwd**: Relative path to the required working directory.
(default is ``""``, the root of the repo)
"""
Expand All @@ -31,6 +41,9 @@ class BaseBackend:

requirements_location: str = LazySettingProperty(default="requirements.txt")
requirements_timeout: int = LazySettingProperty(default=300, convert=int)

pipfile_location: str = LazySettingProperty(default="")

cwd: str = LazySettingProperty(default="")

def __init__(self, **settings):
Expand Down Expand Up @@ -75,31 +88,47 @@ def get_setting(self, key, default=NOT_SET):
raise LazySettingProperty.SettingsNotReady
return self._arca.settings.get(*self.get_settings_keys(key), default=default)

def get_requirements_file(self, path: Path) -> Optional[Path]:
@staticmethod
def hash_file_contents(requirements_option: RequirementsOptions, path: Path) -> str:
""" Returns a SHA256 hash of the contents of ``path`` combined with the Arca version.
"""
return hashlib.sha256(path.read_bytes() +
bytes(requirements_option.name + arca.__version__, "utf-8")).hexdigest()

def get_requirements_information(self, path: Path) -> Tuple[RequirementsOptions, Optional[str]]:
"""
Gets a :class:`Path <pathlib.Path>` for the requirements file if it exists in the provided ``path``,
returns ``None`` otherwise.
Returns the information needed to install requirements for a repository - what kind is used and the hash
of contents of the defining file.
"""
if not self.requirements_location:
return None
if self.pipfile_location is not None:
pipfile = path / self.pipfile_location / "Pipfile"
pipfile_lock = path / self.pipfile_location / "Pipfile.lock"

requirements_file = path / self.requirements_location
pipfile_exists = pipfile.exists()
pipfile_lock_exists = pipfile_lock.exists()

if not requirements_file.exists():
return None
return requirements_file
if pipfile_exists and pipfile_lock_exists:
option = RequirementsOptions.pipfile
return option, self.hash_file_contents(option, pipfile_lock)
elif pipfile_exists:
raise BuildError("Only the Pipfile is included in the repository, Arca does not support that.")
elif pipfile_lock_exists:
raise BuildError("Only the Pipfile.lock file is include in the repository, Arca does not support that.")

if self.requirements_location:
requirements_file = path / self.requirements_location

if requirements_file.exists():
option = RequirementsOptions.requirements_txt
return option, self.hash_file_contents(option, requirements_file)

return RequirementsOptions.no_requirements, None

def serialized_task(self, task: Task) -> Tuple[str, str]:
""" Returns the name of the task definition file and its contents.
"""
return f"{task.hash}.json", task.json

def get_requirements_hash(self, requirements_file: Path) -> str:
""" Returns an SHA1 hash of the contents of the ``requirements_path``.
"""
logger.debug("Hashing: %s%s", requirements_file.read_text(), arca.__version__)
return hashlib.sha256(bytes(requirements_file.read_text() + arca.__version__, "utf-8")).hexdigest()

def run(self, repo: str, branch: str, task: Task, git_repo: Repo, repo_path: Path) -> Result: # pragma: no cover
"""
Executes the script and returns the result.
Expand Down
158 changes: 2 additions & 156 deletions arca/backend/current_environment.py
Original file line number Diff line number Diff line change
@@ -1,172 +1,18 @@
import subprocess
import sys
from enum import Enum
from pathlib import Path
from typing import Optional, Iterable, Set

from git import Repo

from arca.exceptions import ArcaMisconfigured, RequirementsMismatch, BuildError, BuildTimeoutError
from arca.utils import LazySettingProperty, logger
from .base import BaseRunInSubprocessBackend


class RequirementsStrategy(Enum):
""" Enum for defining strategy for :class:`CurrentEnvironmentBackend`
"""

#: Ignores all difference of requirements of the current environment and the target repository.
IGNORE = "ignore"

#: Raises an exception if there are some extra requirements in the target repository.
RAISE = "raise"

#: Installs the extra requirements.
INSTALL_EXTRA = "install_extra"


class CurrentEnvironmentBackend(BaseRunInSubprocessBackend):
""" Uses the current Python to run the tasks, however they're launched in a :mod:`subprocess`.
Available settings:
* **current_environment_requirements**: Path to the requirements file of the current requirements.
Set to ``None`` if there are none. (default is ``requirements.txt``)
* **requirements_strategy**: How should requirements differences be handled.
Can be either strings or a :class:`RequirementsStrategy` value.
See the :class:`RequirementsStrategy` Enum for available strategies
(default is :attr:`RequirementsStrategy.RAISE`)
The requirements of the repository are completely ignored.
"""

current_environment_requirements = LazySettingProperty(default="requirements.txt")
requirements_strategy = LazySettingProperty(default=RequirementsStrategy.RAISE,
convert=RequirementsStrategy)

def install_requirements(self, *, path: Optional[Path] = None, requirements: Optional[Iterable[str]] = None,
_action: str = "install"):
"""
Installs requirements, either from a file or from a iterable of strings.
:param path: :class:`Path <pathlib.Path>` to a ``requirements.txt`` file. Has priority over ``requirements``.
:param requirements: A iterable of strings of requirements to install.
:param _action: For testing purposes, can be either ``install`` or ``uninstall``
:raise BuildError: If installing fails.
:raise ValueError: If both ``file`` and ``requirements`` are undefined.
:raise ValueError: If ``_action`` not ``install`` or ``uninstall``.
"""
if _action not in ["install", "uninstall"]:
raise ValueError(f"{_action} is invalid value for _action")

cmd = [sys.executable, "-m", "pip", _action]

if _action == "uninstall":
cmd += ["-y"]

if path is not None:
cmd += ["-r", str(path)]
elif requirements is not None:
cmd += list(requirements)
else:
raise ValueError("Either path or requirements has to be provided")

logger.info("Installing requirements with command: %s", cmd)

process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

try:
out_stream, err_stream = process.communicate(timeout=self.requirements_timeout)
except subprocess.TimeoutExpired:
process.kill()
raise BuildTimeoutError(f"Installing of requirements timeouted after {self.requirements_timeout} seconds.")

out_stream = out_stream.decode("utf-8")
err_stream = err_stream.decode("utf-8")

logger.debug("Return code is %s", process.returncode)
logger.debug(out_stream)
logger.debug(err_stream)

if process.returncode:
raise BuildError(f"Unable to {_action} requirements from the target repository", extra_info={
"out_stream": out_stream,
"err_stream": err_stream,
"returncode": process.returncode
})

def get_requirements_set(self, file: Path) -> Set[str]:
"""
:param file: :class:`Path <pathlib.Path>` to a ``requirements.txt`` file.
:return: Set of the requirements from the file with newlines and extra characters removed.
"""
return set([x.strip() for x in file.read_text().split("\n") if x.strip()])

def get_or_create_environment(self, repo: str, branch: str, git_repo: Repo, repo_path: Path) -> str:
""" Returns the path to the current Python executable.
"""
Handles the requirements of the target repository (based on ``requirements_strategy``) and returns
the path to the current Python executable.
"""
self.handle_requirements(repo, branch, repo_path)

return sys.executable

def handle_requirements(self, repo: str, branch: str, repo_path: Path):
""" Checks the differences and handles it using the selected strategy.
"""
if self.requirements_strategy == RequirementsStrategy.IGNORE:
logger.info("Requirements strategy is IGNORE")
return

requirements = repo_path / self.requirements_location

# explicitly configured there are no requirements for the current environment
if self.current_environment_requirements is None:

if not requirements.exists():
return # no diff, since no requirements both in current env and repository

requirements_set = self.get_requirements_set(requirements)

if len(requirements_set):
if self.requirements_strategy == RequirementsStrategy.RAISE:
raise RequirementsMismatch(f"There are extra requirements in repository {repo}, branch {branch}.",
diff=requirements.read_text())

self.install_requirements(path=requirements)

# requirements for current environment configured
else:
current_requirements = Path(self.current_environment_requirements)

if not requirements.exists():
return # no req. file in repo -> no extra requirements

logger.info("Searching for current requirements at absolute path %s", current_requirements)
if not current_requirements.exists():
raise ArcaMisconfigured("Can't locate current environment requirements.")

current_requirements_set = self.get_requirements_set(current_requirements)

requirements_set = self.get_requirements_set(requirements)

# only requirements that are extra in repository requirements matter
extra_requirements_set = requirements_set - current_requirements_set

if len(extra_requirements_set) == 0:
return # no extra requirements in repository
else:
if self.requirements_strategy == RequirementsStrategy.RAISE:
raise RequirementsMismatch(f"There are extra requirements in repository {repo}, branch {branch}.",
diff="\n".join(extra_requirements_set))

elif self.requirements_strategy == RequirementsStrategy.INSTALL_EXTRA:
self.install_requirements(requirements=extra_requirements_set)

def _uninstall(self, *args):
""" For usage in tests to uninstall packages from the current environment
:param args: packages to uninstall
"""
self.install_requirements(requirements=args, _action="uninstall")

0 comments on commit d7c9a60

Please sign in to comment.