Skip to content

Commit

Permalink
‼️ BREAKING: Overhaul entire package for virtualenv
Browse files Browse the repository at this point in the history
Overhaul of almost the entire package, with the following main changes:

1. Add a `ProjectConfig` class based on `pydantic`'s `BaseSettings`. It uses
   `python-dotenv` to store the configuration in the `$HOME/.aiida_project.env`
   file. In order to provide integration with `virtualenvwrapper`, the default
   virtual Python environment directory is set `WORKON_HOME` if this variable
   has been set in the UNIX environment.
2. Add a `ProjectDict` configuration with the goal of providing one interface
   for loading and storing the information regarding the created projects.
3. The "engines" have been rebranded as "projects", since they do not only
   create Python virtual environments, but also manage the "project" directory,
   where e.g. the `.aiida` folder is located. The separation of the Python
   environment directory and the `.aiida` one is intentional; it allow the user
   to e.g. complete remove and reset the Python environment easily if needed.
4. Redesign the rebranded `BaseProject` class as a `pydantic.BaseModel`. The
   main idea is that we want to have a clear syntax with type hinting for the
   generation of `BaseProject` instances, that can easily be stored and parsed
   from files.
5. Add the `VirtualenvProject` class to replace `VirtualenvEngine`. It has the
   basic methods needed for creating the environment, adding lines to the
   `activate` and `deactivate` scripts created by `virtualenv`, and allows to
   both install packages from PyPI as well as a local folder.
6. The rebranded `CondaProject` is disabled for now, with the promise of
   extending support for conda environments in a future commit.
7. The `aiida-project create` CLI command is rewritten to accommodate these
   changes.
  • Loading branch information
mbercx committed Apr 21, 2023
1 parent 8fbc7de commit a799ddc
Show file tree
Hide file tree
Showing 12 changed files with 244 additions and 150 deletions.
66 changes: 30 additions & 36 deletions aiida_project/commands/create.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import subprocess
from pathlib import Path

import click
import py

from aiida_project.commands.main import main
from aiida_project.engine import get_engine
from ..commands.main import main
from ..project import get_project

EMPTY_CONFIG = """{
"profiles": {}
}"""

ACTIVATE_AIIDA_SH = """cd {path}
ACTIVATE_AIIDA_SH = """
export AIIDA_PATH={path}
if test -x "$(command -v verdi)"
then
eval "$(_VERDI_COMPLETE=source-bash verdi)"
verdi profile list &> /dev/null && verdi daemon start
fi
"""

Expand All @@ -31,7 +26,7 @@ def clone_pypackage(project_path, repo, branch=None):
subprocess.call(clone_command, cwd=project_path.strpath)


@main.command()
@main.command(context_settings={"show_default": True})
@click.argument("name")
@click.option(
"--engine",
Expand All @@ -41,45 +36,44 @@ def clone_pypackage(project_path, repo, branch=None):
)
@click.option(
"--core-version",
default=None,
help="If specified, immediately installs the corresponding version of `aiida-core`.",
)
@click.option(
"--plugin",
"plugins",
"--plugins",
multiple=True,
help="plugin specifier <github_user>/<repo>:<branch>",
)
@click.option("--python", type=click.Path(file_okay=True, exists=True, dir_okay=False))
def create(name, engine, core_version, plugins, python):
"""Create a new AiiDA project named NAME."""
current_dir = py.path.local(".")
project_path = current_dir.join(name)
from ..config import ProjectConfig, ProjectDict

config = ProjectConfig()

venv_path = config.aiida_venv_dir / Path(name)
project_path = config.aiida_project_dir / Path(name)

project_path.ensure_dir()
config_path = project_path.join(".aiida")
config_path.ensure_dir()
config_file = config_path.join("config.json")
config_file.write(EMPTY_CONFIG)
project = get_project(engine=engine, name=name, project_path=project_path, venv_path=venv_path)

activate_script = project_path.join("activate_aiida.sh")
activate_script.write(ACTIVATE_AIIDA_SH.format(path=project_path.strpath))
click.echo("✨ Creating the project environment and directory.")
project.create(python_path=python)

deactivate_script = project_path.join("deactivate_aiida.sh")
deactivate_script.write(DEACTIVATE_AIIDA_SH)
click.echo("🔧 Adding the AiiDA environment variables to the activate script.")
project.append_activate_text(ACTIVATE_AIIDA_SH.format(path=project_path))
project.append_deactivate_text(DEACTIVATE_AIIDA_SH)

venv = get_engine(engine, project_path)
venv.create(f"aiida-{name}", python=python)
project_dict = ProjectDict()
project_dict.add_project(project)
click.echo("✅ Success! Project created.")

clone_pypackage(project_path, "aiidateam/aiida_core", branch=core_version)
venv.install(project_path.join("aiida_core"))
# clone_pypackage(project_path, "aiidateam/aiida_core", branch=core_version)
if core_version is not None:
click.echo(f"💾 Installing AiiDA core module v{core_version}.")
project.install(f"aiida-core=={core_version}")
else:
click.echo("💾 Installing the latest release of the AiiDA core module.")
project.install("aiida-core")

for plugin in plugins:
repo_branch = plugin.split(":")
repo = repo_branch[0]
core_version = repo_branch[1] if len(repo_branch) > 1 else None
clone_pypackage(project_path, repo, branch=core_version)
venv.install(project_path.join(repo.split("/")[1]))

venv.add_activate_script(activate_script)
venv.add_deactivate_script(deactivate_script)
click.echo(f"💾 Installing {plugin}")
project.install(plugin)
58 changes: 58 additions & 0 deletions aiida_project/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from os import environ
from pathlib import Path
from typing import Dict, Optional, Union

import dotenv
from pydantic import BaseSettings

from .project import EngineType
from .project.base import BaseProject


class ProjectConfig(BaseSettings):
"""Configuration class for configuring `aiida-project`."""

aiida_venv_dir: Path = Path(Path.home(), ".aiida_venvs")
aiida_project_dir: Path = Path(Path.home(), "project")
default_python_path: Optional[Path] = None

class Config:
env_file = Path.home() / Path(".aiida_project.env")
env_file_encoding = "utf-8"

def __init__(self, **configuration):
super().__init__(**configuration)
if dotenv.get_key(self.Config.env_file, "aiida_venv_dir") is None:
dotenv.set_key(
self.Config.env_file,
"aiida_venv_dir",
environ.get("WORKON_HOME", self.aiida_venv_dir.as_posix()),
)


class ProjectDict:
_projects_path = Path(ProjectConfig().aiida_project_dir, ".aiida_projects")

def __init__(self):
if not self._projects_path.exists():
self._projects_path.joinpath("virtualenv").mkdir(parents=True, exist_ok=True)
self._projects_path.joinpath("conda").mkdir(parents=True, exist_ok=True)

@property
def projects(self) -> Dict[str, BaseProject]:
projects = {}
for project_file in self._projects_path.glob("**/*.json"):
engine = EngineType[str(project_file.parent.name)].value
project = engine.parse_file(project_file)
projects[project.name] = project
return projects

def add_project(self, project: BaseProject) -> None:
"""Add a project to the configuration files."""
with Path(self._projects_path, project.engine, f"{project.name}.json").open("w") as handle:
handle.write(project.json())

def remove_project(self, project: Union[str, BaseProject]) -> None:
"""Remove a project from the configuration files."""
project = self.projects[project] if isinstance(project, str) else project
Path(self._projects_path, project.engine, f"{project.name}.json").unlink()
3 changes: 0 additions & 3 deletions aiida_project/engine/__init__.py

This file was deleted.

31 changes: 0 additions & 31 deletions aiida_project/engine/base.py

This file was deleted.

44 changes: 0 additions & 44 deletions aiida_project/engine/conda.py

This file was deleted.

9 changes: 0 additions & 9 deletions aiida_project/engine/factory.py

This file was deleted.

27 changes: 0 additions & 27 deletions aiida_project/engine/virtualenv.py

This file was deleted.

17 changes: 17 additions & 0 deletions aiida_project/project/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from enum import Enum
from pathlib import Path

from .base import BaseProject
from .conda import CondaProject
from .virtualenv import VirtualenvProject


class EngineType(Enum):
virtualenv = VirtualenvProject
conda = CondaProject


def get_project(engine: str, name: str, project_path: Path, venv_path: Path) -> BaseProject:
"""Get a ``BaseProject`` instance using the corresponding environment engine."""

return EngineType[engine].value(name=name, project_path=project_path, venv_path=venv_path)
37 changes: 37 additions & 0 deletions aiida_project/project/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from abc import ABC, abstractmethod
from pathlib import Path

from pydantic import BaseModel


class BaseProject(BaseModel, ABC):
name: str
project_path: Path
venv_path: Path

_engine = ""

@property
def engine(self):
return self._engine

@abstractmethod
def create(self, python_path=None):
"""Create the project."""
Path(self.project_path, ".aiida").mkdir(parents=True, exist_ok=True)

@abstractmethod
def append_activate_text(self, text: str) -> None:
"""Append a text to the activate script."""

@abstractmethod
def append_deactivate_text(self, text: str) -> None:
"""Append a text to the deactivate script."""

@abstractmethod
def install(self, package):
"""Install a package from PyPI."""

@abstractmethod
def install_local(self, path):
"""Install a package from a local directory."""
49 changes: 49 additions & 0 deletions aiida_project/project/conda.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# import json
# import os
# import subprocess

# import py # type: ignore

from aiida_project.project.base import BaseProject


class CondaProject(BaseProject):
"""Conda environment."""

_engine = "conda"


# def __init__(self, project_path):
# super().__init__(project_path)
# self.conda_info = json.loads(
# subprocess.check_output(["conda", "info", "-a", "--json"])
# )

# @property
# def conda_path(self):
# return py.path.local(self.conda_info["conda_prefix"])

# @property
# def venv_path(self):
# for path in self.conda_info["envs_dirs"]:
# env_collection_path = py.path.local(path)
# env_path = env_collection_path.join(self.venv_name)
# if env_path.exists():
# return env_path
# return None

# def create(self, name, python=None):
# self._venv_name = name
# if not self.venv_path:
# conda_command = ["conda", "create", "-y", "-n", name, "python=2"]
# subprocess.check_call(conda_command)

# def add_activate_script(self, path):
# activate_d_path = self.venv_path.join("etc", "conda", "activate.d")
# activate_d_path.ensure_dir()
# os.symlink(path.strpath, activate_d_path.join(path.basename).strpath)

# def add_deactivate_scritp(self, path):
# deactivate_d_path = self.venv_path.join("etc", "conda", "deactivate.d")
# deactivate_d_path.ensure_dir()
# os.symlink(path.strpath, deactivate_d_path.join(path.basename).strpath)
Loading

0 comments on commit a799ddc

Please sign in to comment.