From a6f53f04be571c3dc3c4e2b007b37bd04113a107 Mon Sep 17 00:00:00 2001 From: Simon Kallfass Date: Fri, 9 Aug 2019 19:02:57 +0200 Subject: [PATCH] initial commit --- .gitignore | 24 + .pre-commit-config.yaml | 78 +++ .python-version | 1 + LICENSE.txt | 21 + README.md | 200 +++++++ README.rst | 243 ++++++++ cenv_tool/__init__.py | 8 + cenv_tool/cenv.sh | 75 +++ cenv_tool/cenv.yml | 3 + cenv_tool/init_cenv.py | 124 ++++ cenv_tool/project.py | 277 +++++++++ cenv_tool/rules.py | 106 ++++ cenv_tool/schemata.py | 179 ++++++ cenv_tool/utils.py | 167 ++++++ docs/about.md | 3 + docs/configuration.md | 19 + docs/img/coverage.svg | 21 + docs/img/logo.png | Bin 0 -> 44298 bytes docs/impressum.md | 86 +++ docs/index.md | 39 ++ docs/installation.md | 35 ++ docs/license.md | 21 + docs/usage.md | 110 ++++ mkdocs.yml | 13 + poetry.lock | 563 ++++++++++++++++++ pyproject.toml | 69 +++ setup.cfg | 61 ++ setup.py | 53 ++ tests/.condarc | 17 + tests/home_test/.bashrc | 1 + tests/home_test/.zshrc | 1 + tests/init_cenv_test.py | 32 + .../invalid_testproject/conda-build/meta.yaml | 43 ++ tests/project_test.py | 42 ++ tests/rules_test.py | 15 + tests/testproject/conda-build/meta.yaml | 39 ++ tests/utils_test.py | 95 +++ 37 files changed, 2884 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 README.rst create mode 100644 cenv_tool/__init__.py create mode 100644 cenv_tool/cenv.sh create mode 100644 cenv_tool/cenv.yml create mode 100644 cenv_tool/init_cenv.py create mode 100644 cenv_tool/project.py create mode 100644 cenv_tool/rules.py create mode 100644 cenv_tool/schemata.py create mode 100644 cenv_tool/utils.py create mode 100644 docs/about.md create mode 100644 docs/configuration.md create mode 100644 docs/img/coverage.svg create mode 100644 docs/img/logo.png create mode 100644 docs/impressum.md create mode 100644 docs/index.md create mode 100644 docs/installation.md create mode 100644 docs/license.md create mode 100644 docs/usage.md create mode 100644 mkdocs.yml create mode 100644 poetry.lock create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/.condarc create mode 100644 tests/home_test/.bashrc create mode 100644 tests/home_test/.zshrc create mode 100644 tests/init_cenv_test.py create mode 100644 tests/invalid_testproject/conda-build/meta.yaml create mode 100644 tests/project_test.py create mode 100644 tests/rules_test.py create mode 100644 tests/testproject/conda-build/meta.yaml create mode 100644 tests/utils_test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..324395c --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +*.cache +*.log +*.log.* +*.pid +*.wpr +*.wpu +*.a +*.o +*.py[co] +*.so +*.sw[nop] +__pycache__ +*~ +.#* +[#]*# +dropin.cache +RE:\.?[^.]+ +*.e4p +.pytest_cache +environment.yml +dist +*.egg-info +.coverage +htmlcov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..4b7e0bd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,78 @@ +# Info: +# ----- +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +fail_fast: True +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.2.3 + hooks: + #- id: no-commit-to-branch + #- args: [--branch, master] + - id: check-docstring-first + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + - id: end-of-file-fixer + - id: check-merge-conflict + - id: double-quote-string-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: check-executables-have-shebangs + - id: fix-encoding-pragma + - id: check-case-conflict + - id: name-tests-test + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.4.1 + hooks: + - id: python-use-type-annotations + - id: python-no-log-warn + - repo: https://github.com/asottile/reorder_python_imports + rev: v1.6.0 + hooks: + - id: reorder-python-imports + - repo: https://github.com/asottile/add-trailing-comma + rev: v1.4.1 + hooks: + - id: add-trailing-comma + - repo: https://github.com/pre-commit/mirrors-yapf + rev: v0.28.0 + hooks: + - id: yapf + types: [python] + always_run: true + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.2.3 + hooks: + - id: check-ast + - repo: https://gitlab.com/pycqa/flake8 + rev: ace069c9c3585b4c74348f94da313bbc020dfc73 + hooks: + - id: flake8 + - repo: https://github.com/codespell-project/codespell + rev: 70196e35a7baa730e26ab6a0da77b5e68d980611 + hooks: + - id: codespell + - repo: local + hooks: + - id: pytest + name: pytest + description: running pytest + entry: bash -c "$VIRTUAL_ENV/bin/pytest" + language: system + types: [python] + pass_filenames: false + - id: coverage-badge + name: coverage-badge + description: create the coverage badge + entry: bash -c "$VIRTUAL_ENV/bin/coverage-badge -f -o docs/img/coverage.svg" + language: system + types: [python] + pass_filenames: false + - id: build-package + name: build the package for the repo + description: Generate the dist packages for this repo + language: system + entry: dephell project build + files: "^pyproject.toml$" + pass_filenames: false diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c1e43e6 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.7.3 diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..268f2a9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Simon Kallfass + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2d0cb2 --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# conda-env-manager: cenv + +![coverage](docs/img/coverage.svg) +[![PyPI version fury.io](https://badge.fury.io/py/ansicolortags.svg)](https://pypi.python.org/pypi/cenv_tool/) +[![PyPI pyversions](https://img.shields.io/pypi/pyversions/ansicolortags.svg)](https://pypi.python.org/pypi/cenv_tool/) +[![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](https://lbesson.mit-license.org/) + +![logo](docs/img/logo.png) + +Due to the redundant dependency information inside the `meta.yaml` (required +to create the conda-package) and the `environment.yml` (as definition file +for the conda-environment during development and for production), `cenv` +(short form for `conda-env-manager`) was created to make the `meta.yaml` +the only relevant file and to create and update conda-environment from the +definition inside this `meta.yaml`. +The name of the conda-environment to create / update is defined in the section +`extra` and the variable `env_name` inside the `meta.yaml`. + +The steps run by cenv: + +* creation of a backup if the environment already exists followed by the + removal of the previous environment. +* creation of the environment as defined in the `meta.yaml`. + If any failures occurred during creation and the backup was created, the + command to reset the backup-version can be used. +* if enabled in the config file the environment.yml is exported after creation + / update of the environment. + + +The usage of cenv reduces the conda commands to use to the following: + +* `conda activate ...` to activate the environment +* `conda deactivate` to deactivate an environment +* `conda info` to show information about the currently activated environment +* `conda search ...` to search for availability of a package in the conda + channels. +* `conda remove -n ... --all` to remove an environment +* `cenv` to create / update an environment + + +## Installation + +Install `cenv` using pip: +```bash +pip3 install cenv_tool +``` + +Now run `init_cenv` to create the relevant config-files and add the +autoactivate- and autoupdate-shell-function to your `.bashrc` / `.zshrc`. + + +### autoactivate and autoupdate + +Per default these features are deactivated, even if added to your shell by +running `init_cenv`. + + +#### autoactivate-feature + +The autoactivate-feature activates the conda-environment as named +`extra`-section in the meta.yaml located at `conda-build/meta.yaml`, if the +environment exists. +To activate the autoactivate-features run: +```bash +autoactivate_toggle +``` + +#### autoupdate-feature + +The autoupdate checks if the content of the meta.yaml changed. +The current state is stored as a md5sum in `conda-build/meta.md5`. +If it changed the cenv-process is called. + +For the autoupdate-feature run: +```bash +autoupdate_toggle +``` + + +## Usage + +All steps required to create or update the projects conda environment are +run automatically running: +```bash +cenv +``` + +**ATTENTION**: +> If you use cenv, each environment should only be created, updated and +> modified using `cenv`! +> This means the commands `conda install`, `conda remove` are not used +> anymore. +> Changes of the dependencies of the environment are defined inside the +> `meta.yaml` and are applied by using `cenv`. +> +> This means: +> +> * new dependency required => add it in `meta.yaml` and run `cenv`. +> * dependency not needed anymore => remove it from `meta.yaml` and run +> `cenv`. +> * need of another version of dependency => change the version of dependency +> in `meta.yaml` and run `cenv`. + +The required information about the projects conda environment are extracted +from the meta.yaml. +This meta.yaml should be located inside the project folder at +`./conda-build/meta.yaml`. +The project-configuration is defined in the `extra` section of the `meta.yaml`. +There you can define the name of the projects conda-environment at +`env_name`. +Also you can define requirements only needed during development but not to be +included into the resulting conda package. +These requirements have to be defined in the `dev_requirements`-section. + +All other parts of the `meta.yaml` have to be defined as default. + +A meta.yaml valid for cenv should look like the following: +```yaml + {% set data = load_setup_py_data() %} + + package: + name: "example_package" + version: {{ data.get("version") }} + + source: + path: .. + + build: + build: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} + preserve_egg_dir: True + script: python -m pip install --no-deps --ignore-installed . + + requirements: + build: + - python 3.6.8 + - pip + - setuptools + run: + - python 3.6.8 + - attrs >=18.2 + - jinja2 >=2.10 + - ruamel.yaml >=0.15.23 + - six >=1.12.0 + - yaml >=0.1.7 + - marshmallow >=3.0.0rc1* + + test: + imports: + - example_package + + extra: + env_name: example + dev_requirements: + - ipython >=7.2.0 +``` + +**ATTENTION**: +> In the `requirements-run-section` the minimal version of each package +> has to be defined! +> The same is required for the `dev_requirements`-section. +> Not defining a version will not create or update a conda-environment, +> because this is not the purpose of the conda-usage. +> The validity of the `meta.yaml` is checked in `cenv` using the +> `marshmallow` package. +> You can additionally add upper limits for the version like the following: +> `- package >=0.1,<0.3` + +If cenv is run the environment is created / updated from the definition inside +this `meta.yaml`. +The creation of the backup of the previous environment ensures to undo changes +if any error occurs during recreation of the environment. + + +**ATTENTION**: +> `cenv` can only update the environment if it is not activated. +> So ensure the environment to be deactivated before running `cenv`. + +Per default exporting the conda environment definition into an environment.yml +is turned off. +If you want to turn this functionality on you need to modify your +`~/.config/cenv.yml` as described in the configuration-part. + +Example for the output of the `cenv` command: + +```bash + ┣━━ Cloning existing env as backup ... + ┣━━ Removing existing env ... + ┣━━ Creating env ... + ┣━━ Removing backup ... + ┗━━ Exporting env to environment.yml ... +``` + +# Development of cenv + +To create the environment to develop cenv run the pre-commit hooks manually: +```bash +pyenv local 3.7.3 +pre-commit run --all-files +poetry shell +``` diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..48a5079 --- /dev/null +++ b/README.rst @@ -0,0 +1,243 @@ + +conda-env-manager: cenv +======================= + + +.. image:: coverage.svg + :target: coverage.svg + :alt: coverage + + +.. image:: https://badge.fury.io/py/ansicolortags.svg + :target: https://pypi.python.org/pypi/cenv_tool/ + :alt: PyPI version fury.io + + +.. image:: https://img.shields.io/pypi/pyversions/ansicolortags.svg + :target: https://pypi.python.org/pypi/cenv_tool/ + :alt: PyPI pyversions + + +.. image:: https://img.shields.io/badge/License-MIT-blue.svg + :target: https://lbesson.mit-license.org/ + :alt: MIT license + + + +.. image:: docs/img/logo.png + :target: docs/img/logo.png + :alt: logo + + +Due to the redundant dependency information inside the ``meta.yaml`` (required +to create the conda-package) and the ``environment.yml`` (as definition file +for the conda-environment during development and for production), ``cenv`` +(short form for ``conda-env-manager``\ ) was created to make the ``meta.yaml`` +the only relevant file and to create and update conda-environment from the +definition inside this ``meta.yaml``. +The name of the conda-environment to create / update is defined in the section +``extra`` and the variable ``env_name`` inside the ``meta.yaml``. + +The steps run by cenv: + + +* creation of a backup if the environment already exists followed by the + removal of the previous environment. +* creation of the environment as defined in the ``meta.yaml``. + If any failures occurred during creation and the backup was created, the + command to reset the backup-version can be used. +* if enabled in the config file the environment.yml is exported after creation + / update of the environment. + +The usage of cenv reduces the conda commands to use to the following: + + +* ``conda activate ...`` to activate the environment +* ``conda deactivate`` to deactivate an environment +* ``conda info`` to show information about the currently activated environment +* ``conda search ...`` to search for availability of a package in the conda + channels. +* ``conda remove -n ... --all`` to remove an environment +* ``cenv`` to create / update an environment + +Installation +------------ + +Install ``cenv`` using pip: + +.. code-block:: bash + + pip3 install cenv_tool + +Now run ``init_cenv`` to create the relevant config-files and add the +autoactivate- and autoupdate-shell-function to your ``.bashrc`` / ``.zshrc``. + +autoactivate and autoupdate +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Per default these features are deactivated, even if added to your shell by +running ``init_cenv``. + +autoactivate-feature +~~~~~~~~~~~~~~~~~~~~ + +The autoactivate-feature activates the conda-environment as named +``extra``\ -section in the meta.yaml located at ``conda-build/meta.yaml``\ , if the +environment exists. +To activate the autoactivate-features run: + +.. code-block:: bash + + autoactivate_toggle + +autoupdate-feature +~~~~~~~~~~~~~~~~~~ + +The autoupdate checks if the content of the meta.yaml changed. +The current state is stored as a md5sum in ``conda-build/meta.md5``. +If it changed the cenv-process is called. + +For the autoupdate-feature run: + +.. code-block:: bash + + autoupdate_toggle + +Usage +----- + +All steps required to create or update the projects conda environment are +run automatically running: + +.. code-block:: bash + + cenv + +**ATTENTION**\ : + +.. + + If you use cenv, each environment should only be created, updated and + modified using ``cenv``\ ! + This means the commands ``conda install``\ , ``conda remove`` are not used + anymore. + Changes of the dependencies of the environment are defined inside the + ``meta.yaml`` and are applied by using ``cenv``. + + This means: + + + * new dependency required => add it in ``meta.yaml`` and run ``cenv``. + * dependency not needed anymore => remove it from ``meta.yaml`` and run + ``cenv``. + * need of another version of dependency => change the version of dependency + in ``meta.yaml`` and run ``cenv``. + + +The required information about the projects conda environment are extracted +from the meta.yaml. +This meta.yaml should be located inside the project folder at +``./conda-build/meta.yaml``. +The project-configuration is defined in the ``extra`` section of the ``meta.yaml``. +There you can define the name of the projects conda-environment at +``env_name``. +Also you can define requirements only needed during development but not to be +included into the resulting conda package. +These requirements have to be defined in the ``dev_requirements``\ -section. + +All other parts of the ``meta.yaml`` have to be defined as default. + +A meta.yaml valid for cenv should look like the following: + +.. code-block:: yaml + + {% set data = load_setup_py_data() %} + + package: + name: "example_package" + version: {{ data.get("version") }} + + source: + path: .. + + build: + build: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} + preserve_egg_dir: True + script: python -m pip install --no-deps --ignore-installed . + + requirements: + build: + - python 3.6.8 + - pip + - setuptools + run: + - python 3.6.8 + - attrs >=18.2 + - jinja2 >=2.10 + - ruamel.yaml >=0.15.23 + - six >=1.12.0 + - yaml >=0.1.7 + - marshmallow >=3.0.0rc1* + + test: + imports: + - example_package + + extra: + env_name: example + dev_requirements: + - ipython >=7.2.0 + +**ATTENTION**\ : + +.. + + In the ``requirements-run-section`` the minimal version of each package + has to be defined! + The same is required for the ``dev_requirements``\ -section. + Not defining a version will not create or update a conda-environment, + because this is not the purpose of the conda-usage. + The validity of the ``meta.yaml`` is checked in ``cenv`` using the + ``marshmallow`` package. + You can additionally add upper limits for the version like the following: + ``- package >=0.1,<0.3`` + + +If cenv is run the environment is created / updated from the definition inside +this ``meta.yaml``. +The creation of the backup of the previous environment ensures to undo changes +if any error occurs during recreation of the environment. + +**ATTENTION**\ : + +.. + + ``cenv`` can only update the environment if it is not activated. + So ensure the environment to be deactivated before running ``cenv``. + + +Per default exporting the conda environment definition into an environment.yml +is turned off. +If you want to turn this functionality on you need to modify your +``~/.config/cenv.yml`` as described in the configuration-part. + +Example for the output of the ``cenv`` command: + +.. code-block:: bash + + ┣━━ Cloning existing env as backup ... + ┣━━ Removing existing env ... + ┣━━ Creating env ... + ┣━━ Removing backup ... + ┗━━ Exporting env to environment.yml ... + +Development of cenv +=================== + +To create the environment to develop cenv run the pre-commit hooks manually: + +.. code-block:: bash + + pyenv local 3.7.3 + pre-commit run --all-files + poetry shell diff --git a/cenv_tool/__init__.py b/cenv_tool/__init__.py new file mode 100644 index 0000000..c8c3ccc --- /dev/null +++ b/cenv_tool/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +"""Conda environment creation and update from meta.yaml.""" +from pkg_resources import get_distribution + +try: + __version__ = get_distribution('cenv_tool').version +except AttributeError: + __version__ = '' diff --git a/cenv_tool/cenv.sh b/cenv_tool/cenv.sh new file mode 100644 index 0000000..ffc2558 --- /dev/null +++ b/cenv_tool/cenv.sh @@ -0,0 +1,75 @@ +source /opt/conda/etc/profile.d/conda.sh +export AUTOACTIVATE=1 +export AUTOUPDATE=1 +export meta_yaml_path=conda-build/meta.yaml +export meta_yaml_md5_path=conda-build/meta.md5 +CYAN='\033[0;94m' +GREEN='\033[1;32m' +RED='\033[1;91m' +COLOR_END='\033[0m' +AS_BOLD='\033[1;37m' + + + +function autoactivate_toggle(){ + if (( $AUTOACTIVATE == 1 )); then + export AUTOACTIVATE=0 + else + export AUTOACTIVATE=1 + fi +} + + +function autoupdate_toggle(){ + if (( $AUTOUPDATE == 1 )); then + export AUTOUPDATE=0 + else + export AUTOUPDATE=1 + fi +} + + +function get_envname_from_meta(){ + if [ -e $meta_yaml_path ]; then + while IFS='' read -r line || [[ -n "$line" ]]; do + if [[ $line == *"env_name"* ]]; then + echo $( echo "$line" | cut -d' ' -f 6-) + fi + done < $meta_yaml_path; + fi +} + + +function autoactivate_env() { + if (( $AUTOUPDATE == 1 )); then + if [ -e $PWD/$meta_yaml_md5_path ]; then + local new_md5=$(echo "$(md5sum $PWD/$meta_yaml_path)" | cut -d' ' -f1) + local current_md5="$(head -n 1 $PWD/$meta_yaml_md5_path)" + if ! [ $new_md5 = $current_md5 ]; then + conda deactivate + cenv + fi + fi + fi + + if (( $AUTOACTIVATE == 1 )); then + if [ -e $PWD/$meta_yaml_path ]; then + local env="$(get_envname_from_meta)" + local env="${env%\"}" + local env="${env#\"}" + if [[ $PATH != *$env* ]]; then + if conda activate $env 2>/dev/null && [[ $? -eq 0 ]]; then + CONDA_ENV_ROOT="$(pwd)" + PYTHONPATH=.:$PYTHONPATH + echo -e "${GREEN}"'\u2714'" activated${COLOR_END} $env" + fi + fi + elif [[ $PATH = */envs/* ]] && [[ $(pwd) != $CONDA_ENV_ROOT ]] \ + && [[ $(pwd) != $CONDA_ENV_ROOT/* ]]; then + CONDA_ENV_ROOT="" + conda deactivate + unset PYTHONPATH + echo -e "${RED}"'\u2718'" deactivated${COLOR_END} env" + fi + fi +} diff --git a/cenv_tool/cenv.yml b/cenv_tool/cenv.yml new file mode 100644 index 0000000..5339b4a --- /dev/null +++ b/cenv_tool/cenv.yml @@ -0,0 +1,3 @@ +conda_folder: /opt/conda +env_folder: /shared/conda/envs +export_environment_yml: false diff --git a/cenv_tool/init_cenv.py b/cenv_tool/init_cenv.py new file mode 100644 index 0000000..ed57115 --- /dev/null +++ b/cenv_tool/init_cenv.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +"""Install config and cenv.sh.""" +from pathlib import Path +from typing import NoReturn + +from cenv_tool.utils import message + +CONFIG_PATH = Path.home() / '.config/cenv' +AUTOENV_SCRIPT_PATH = CONFIG_PATH / 'cenv.sh' +AUTOENV_SCRIPT_SOURCE_PATH = Path(__file__).with_name('cenv.sh') +CONFIG_FILE = CONFIG_PATH / 'cenv.yml' +CONFIG_FILE_SOURCE = Path(__file__).with_name('cenv.yml') +ZSHRC = Path.home() / '.zshrc' +BASHRC = Path.home() / '.bashrc' + +RC_CONTENT = """ +# ======================================================================>>>>>>> +# AUTOMATICALLY ADDED BY CENV TO ENABLE AUTOUPDATE AND AUTOACTIVATE + +# load the cenv shell functions +source $HOME/.config/cenv/cenv.sh + +# 0 means deactivated, 1 activated +# disable autoactivate +export AUTOACTIVATE=0 +# disable autoupdate +export AUTOUPDATE=0 + +# enable the autoactivation of conda-environments +precmd() { autoactivate_env; } +# <----- +# <<<<<<<====================================================================== +""" + + +def initialize_cenv( + config_path: Path, + autoenv_script_path: Path, + autoenv_script_source_path: Path, + config_file: Path, + config_file_source: Path, + zshrc: Path, + bashrc: Path, +) -> NoReturn: + """Install user-config and cenv.sh for autoactivate and autoupdate. + + Parameters: + config_path: the path for cenv config-stuff. + autoenv_script_path: the path to install the cenv.sh script to. + autoenv_script_source_path: the path where to get the cenv.sh script + from + config_file: the path to install the user-config into. + config_file_source: the path where to get the config file from. + zshrc: the path to the users .zshrc + bashrc: the path to the users .bashrc + + """ + if not config_path.exists(): + message(text='creating cenv-config folder', color='cyan') + config_path.mkdir(parents=True) + message(text='created cenv-config folder', color='green') + else: + message(text='cenv-config folder already exists', color='green') + + if not autoenv_script_path.exists(): + message( + text=( + 'copying script for autoactivation and autoupdate to ' + 'config-folder' + ), + color='cyan', + ) + autoenv_script_path.write_text(autoenv_script_source_path.read_text()) + message( + text='copied script for autoactivateion and autoupdate', + color='green', + ) + else: + message( + text='script for autoactivation and autupdate already copied', + color='green', + ) + + if not config_file.exists(): + message( + text='copying config-file to cenv-config-folder ...', + color='cyan', + ) + config_file.write_text(config_file_source.read_text()) + message(text='copied config-file', color='green') + else: + message(text='config-file already exists', color='green') + + for shell_config in (zshrc, bashrc): + if shell_config.exists(): + if RC_CONTENT not in shell_config.read_text(): + message( + text=f'initilized cenv in {shell_config}', + color='green', + ) + with open(shell_config, 'a') as opened_shell_config: + opened_shell_config.write(RC_CONTENT) + else: + message( + text=f'cenv already initialized in {shell_config}', + color='green', + ) + + +def main(): + """Call the initialization function to install config and cenv.sh.""" + initialize_cenv( + config_path=CONFIG_PATH, + autoenv_script_path=AUTOENV_SCRIPT_PATH, + autoenv_script_source_path=AUTOENV_SCRIPT_SOURCE_PATH, + config_file=CONFIG_FILE, + config_file_source=CONFIG_FILE_SOURCE, + zshrc=ZSHRC, + bashrc=BASHRC, + ) + + +if __name__ == '__main__': + main() diff --git a/cenv_tool/project.py b/cenv_tool/project.py new file mode 100644 index 0000000..af75354 --- /dev/null +++ b/cenv_tool/project.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +"""Contain the processing for creating conda environments from the meta.yaml. + +cenv is a tool to handle conda-environment creation and update from the +dependency-definition inside the meta.yaml file. + +As default conda has two files for dependency management: +* the environment.yml +* and the meta.yaml + +In the environment.yml the environment-definition is stored. +In the meta.yaml the required information to build a conda-package are +stored. This means redundant information. + +cenv collects the dependency-information and all project-specific settings +from the meta.yaml-file. + +The collected information is used to create / update the projects conda +environment. +""" +from argparse import ArgumentParser +from argparse import Namespace +from argparse import RawTextHelpFormatter +from pathlib import Path +from typing import List +from typing import NoReturn + +import attr + +from cenv_tool import __version__ +from cenv_tool.rules import ARGPARSE_DESCRIPTION +from cenv_tool.rules import CondaCmdFormats +from cenv_tool.rules import RULES +from cenv_tool.rules import Rules +from cenv_tool.utils import CenvProcessError +from cenv_tool.utils import message +from cenv_tool.utils import read_config +from cenv_tool.utils import read_meta_yaml +from cenv_tool.utils import run_in_bash + + +@attr.s(slots=True, auto_attribs=True) +class Project: + """Contain a python-project using conda-environments. + + Containing methods to display information to current project and methods + to update the projects conda-environment from the settings defined in the + projects meta.yaml file. + """ + + rules: Rules + conda_folder: Path = attr.ib(default=None) + env_folder: Path = attr.ib(default=None) + env_name: str = attr.ib(default=None) + dependencies: dict = attr.ib(default=None) + is_env: bool = attr.ib(default=None) + export_environment_yml: bool = attr.ib(None) + cmds: CondaCmdFormats = attr.ib(default=None) + cmd_kwargs: dict = attr.ib(default=None) + is_git: bool = attr.ib(default=None) + + def __attrs_post_init__(self): + """Set the more complex attributes of the project class.""" + try: + meta_yaml, dependencies = read_meta_yaml(Path.cwd()) + settings = meta_yaml['extra'] + except FileNotFoundError: + message(text='project has no meta.yaml!', color='red') + exit(1) + + config = read_config() + self.is_git = (Path.cwd() / self.rules.git_folder).exists() + self.export_environment_yml = config['export_environment_yml'] + self.conda_folder = Path(config['conda_folder']) + self.env_folder = Path(config['env_folder']) + self.env_name = settings['env_name'] + self.dependencies = dependencies + self.is_env = self.env_name in self.collect_available_envs() + conda_bin = self.rules.conda_cmds.conda_bin(self.conda_folder) + self.cmds = self.rules.conda_cmds + self.cmd_kwargs = { + 'conda': conda_bin, + 'name': self.env_name, + 'pkgs': ' '.join([f'"{_}"' for _ in self.dependencies]), + } + + def collect_available_envs(self) -> List[str]: + """Collect the names of the conda-environments currently installed. + + Parameters: + conda_folder: the path where conda is installed. + + Returns: + List of currently installed conda-environments + + """ + return run_in_bash( + str(self.conda_folder.absolute()) + + '/bin/conda env list | awk \'{ if( !($1=="#") ) print $1 }\'', + ).split('\n') + + def write_new_md5sum(self): + """Write the new md5sum of the meta.yaml to conda-build/meta.md5.""" + message(text='write md5sum of meta.yaml', color='bold', special='row') + command = ( + 'echo "$(md5sum $PWD/conda-build/meta.yaml)" | ' + 'cut -d\' \' -f1 > $PWD/conda-build/meta.md5' + ) + run_in_bash(cmd=command) + message(text='updated', color='green', special='end', indent=2) + + def export_environment_definition(self) -> NoReturn: + """Export the projects environment definition to an environment.yml.""" + message(text='Export environment.yml ...', color='bold', special='row') + run_in_bash(cmd=self.cmds.export.format(**self.cmd_kwargs)) + message(text='Exported', color='green', special='end', indent=2) + + def remove_backup_environment(self) -> NoReturn: + """Remove backup cloned from original environment.""" + run_in_bash(cmd=self.cmds.clean.format(**self.cmd_kwargs)) + + def restore_environment_from_backup(self, cloned: bool) -> NoReturn: + """Restore the environment from the cloned backup environment. + + After restore the backup environment is removed. + + Parameters: + cloned: indicates if the environment already existed and a backup + was created. + + """ + message(text='Error during creation!', color='red', special='row') + if self.is_env and cloned: + message(text='Recreating backup', color='bold', special='row') + run_in_bash(cmd=self.cmds.restore.format(**self.cmd_kwargs)) + self.remove_backup_environment() + message(text='Recreated', color='green', special='end', indent=2) + message(text='Exit', color='red', special='end') + + def remove_previous_environment(self) -> NoReturn: + """Remove old version of project environment. + + If the old environment can't be removed, the backup made is removed. + """ + try: + message(text='Remove existing env', color='bold', special='row') + run_in_bash(cmd=self.cmds.remove.format(**self.cmd_kwargs)) + message(text='Removed', color='green', special='end', indent=2) + except CenvProcessError: + self.remove_backup_environment() + message( + text=( + 'Could not remove environment because it is ' + 'activated! Please deactivate it first.' + ), + color='red', + ) + exit(1) + + def clone_environment_as_backup(self) -> NoReturn: + """Clone the existing environment as a backup. + + If the backup already exists, the previous backup is removed, then + the new one is created by cloning the current project environment. + """ + backup_name = f'{self.env_name}_backup' + if backup_name in self.collect_available_envs(): + message(text='Clear old backup', color='bold', special='row') + self.remove_backup_environment() + message(text='Cleared', color='green', special='end', indent=2) + message(text='Create backup', color='bold', special='row') + run_in_bash(cmd=self.cmds.clone.format(**self.cmd_kwargs)) + message(text='Created', color='green', special='end', indent=2) + + def handle_existing_environment(self) -> bool: + """Check if environment already exists and create a backup of it.""" + if self.is_env: + self.clone_environment_as_backup() + self.remove_previous_environment() + return True + + return False + + def create_environment(self, cloned: bool) -> NoReturn: + """Create the environment for the project. + + Try to create the environment for the project. If the environment + already existed and a backup was made and any error occurs, the backup + environment is restored. + If everything worked correctly the backup (if made) is finally + removed. + + Parameters: + cloned: indicates if the environment already existed and a backup + was created. + + """ + message(text='Create environment', color='bold', special='row') + + try: + run_in_bash(cmd=self.cmds.create.format(**self.cmd_kwargs)) + except CenvProcessError: + self.restore_environment_from_backup(cloned=cloned) + raise + + if cloned: + message(text='Clear backup', color='bold', special='row', indent=2) + run_in_bash(cmd=self.cmds.clean.format(**self.cmd_kwargs)) + message(text='Cleared', color='green', special='end', indent=3) + + message(text='Created', color='green', special='end', indent=2) + + def update(self) -> NoReturn: + """Create / recreate the conda-environment of the current project. + + If the conda-environment already exists the user is aked for + confirmation to continue. Then the environment will be cloned as a + backup and the original environment will be removed. Now the new + conda-environment will be created. If a backup was created it is + removed afterwards. If any errors occurs during creation of the new + environment the old environment will be recreated from backup and + then the backup will be removed. If activated in the config-file, the + environment-definition of the created environment is exported to an + environment.yml file. Finally the md5sum of the meta.yaml is stored + for the autoupdate feature. + """ + if self.is_env: + message(text=f'Updating {self.env_name}', color='cyan') + else: + message(text=f'Creating {self.env_name}', color='cyan') + + cloned = self.handle_existing_environment() + + self.create_environment(cloned=cloned) + + if self.export_environment_yml: + self.export_environment_definition() + + self.write_new_md5sum() + + message(text='Done', color='green', special='end') + + +def build_arguments() -> Namespace: + """Create arguments for the cenv-tool. + + Returns: + The parsed arguments + + """ + parser = ArgumentParser( + description=ARGPARSE_DESCRIPTION, + epilog='For additional information see http://www.cenv.ouroboros.info', + formatter_class=RawTextHelpFormatter, + ) + parser.add_argument( + '-v', + '--version', + action='store_true', + default=False, + help='Show current version of cenv and exit.', + ) + args = parser.parse_args() + return args + + +def main() -> NoReturn: + """Collect the required args, initializing and running the Project.""" + args = build_arguments() + if args.version: + print(__version__) + else: + Project(rules=RULES).update() + + +if __name__ == '__main__': + main() diff --git a/cenv_tool/rules.py b/cenv_tool/rules.py new file mode 100644 index 0000000..2f430f3 --- /dev/null +++ b/cenv_tool/rules.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""Rules-definitions required by cenv.""" +import attr + +EXAMPLE_META_YAML = """ + {% set data = load_setup_py_data() %} + + package: + name: "example_package" + version: {{ data.get("version") }} + + source: + path: .. + + build: + build: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} + preserve_egg_dir: True + script: python -m pip install --no-deps --ignore-installed . + + requirements: + build: + - python 3.6.8 + - pip + - setuptools + run: + - python 3.6.8 + - attrs >=18.2 + - marshmallow >=3.0.0rc1* + + test: + imports: + - example_package + + extra: + env_name: example + dev_requirements: + - ipython >=7.2.0 + - pylint >=2.3.1 +""" + +ARGPARSE_DESCRIPTION = ( + """ + Create / update conda environments from meta.yaml definition. + Due to the redundant dependency information inside the + meta.yaml (required to create conda-package) + and the environment.yml (definition for conda environment), + cenv (short for `conda-env-manager`) was created to make the + meta.yaml the only relevant file for creation and update of + conda environments. + The name of the conda-environment to create / update is defined + in the section "extra" and the variable "env_name" inside the + meta.yaml. + Dependencies and their versions are extracted from the + "requirements-run"-section of the meta.yaml. + Dependencies required during development are defined in the + "dev_requirements"-section. + + Steps run by cenv: + * Cloning existing environment as backup if already exists + * Removing existing environment if already exists + * Creating environment from definition in meta.yaml + * Removing backup environment if everything worked as expected + * Exporting env to environment.yml (only if activated in config)' + + + IMPORTANT: + if you do not use the functionalities "autoactivate" and + "autoupdate" from cenv.sh you have to deactivate the + environment before running cenv. + + + An example for a valid meta.yaml: + """ + EXAMPLE_META_YAML +) + + +@attr.s(slots=True) +class CondaCmdFormats: + """Contain the formats for the conda commands to use inside cenv.""" + + remove = '{conda} remove -n {name} --all -y' + export = '{conda} env export -n {name} > conda-build/environment.yml' + create = '{conda} create -n {name} {pkgs} -y' + clone = '{conda} create -n {name}_backup --clone {name} -y' + restore = '{conda} create -n {name} --clone {name}_backup -y' + clean = '{conda} remove -n {name}_backup --all -y' + + def conda_bin(self, conda_folder): + """Combine the path of conda-folder with subpath of conda-bin. + + Returns: + the path to the conda-executable + + """ + return (conda_folder / 'bin/conda').absolute() + + +@attr.s(slots=True) +class Rules: + """Contain the rules required by cenv-tool.""" + + conda_cmds = CondaCmdFormats() + git_folder = '.git' + + +RULES = Rules() diff --git a/cenv_tool/schemata.py b/cenv_tool/schemata.py new file mode 100644 index 0000000..31d46a3 --- /dev/null +++ b/cenv_tool/schemata.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +"""Contain schemata required by cenv-tool.""" +from marshmallow import fields +from marshmallow import Schema +from marshmallow import validate + + +class SNPackage(Schema): + """Contain the package-section inside a meta.yaml.""" + + name = fields.String( + strict=True, + required=True, + ) + version = fields.String( + strict=True, + required=True, + ) + + +class SNSource(Schema): + """Contain the source-section inside a meta.yaml.""" + + path = fields.String( + strict=True, + required=True, + ) + + +class SNBuild(Schema): + """Contain the build-section inside a meta.yaml. + + Schema for the build-section inside a meta.yaml file, to check this section + of a given meta.yaml file in cenv, to be valid. + The build-section requires to define the build-number, if the egg-dir + should be preserved, the script to run on installation and if any + entrypoints are defined for the package. + """ + + build = fields.String( + strict=True, + required=True, + ) + preserve_egg_dir = fields.String( + strict=True, + required=True, + validate=validate.OneOf(['True', 'False']), + ) + script = fields.String( + strict=True, + required=True, + ) + entry_points = fields.List( + fields.String(strict=True, required=False), + strict=True, + required=False, + ) + + +class SNRequirements(Schema): + """Contain requirements-section inside a meta.yaml. + + Schema for the requirements-section inside a meta.yaml file, to check this + section of a given meta.yaml file in cenv, to be valid. + The underlying build- and run-sections have to be valid! + """ + + build = fields.List( + fields.String( + strict=True, + required=True, + ), + strict=True, + required=True, + ) + run = fields.List( + fields.String( + strict=True, + required=True, + validate=lambda x: '=' in x if 'python ' not in x else True, + error_messages=dict(validator_failed='Version must be specified'), + ), + strict=True, + required=True, + ) + + +class SNTest(Schema): + """Contain tests-section inside a meta.yaml. + + Schema for the test-section inside a meta.yaml file, to check this section + of a given meta.yaml file in cenv, to be valid. + """ + + imports = fields.List( + fields.String( + strict=True, + required=False, + ), + strict=True, + required=False, + ) + commands = fields.List( + fields.String( + strict=True, + required=False, + ), + strict=True, + required=False, + ) + + +class SNExtra(Schema): + """Contain the extra-section inside a meta.yaml. + + Schema for the extra-section inside a meta.yaml file, to check this + section of a given meta.yaml file in cenv, to be valid. + The extra-section has to contains the information where to find the + conda-folder, the name of the conda-environment to use for the current + project and the cenv-version used when the meta.yaml file was created. + """ + + env_name = fields.String( + strict=True, + required=True, + validate=lambda x: ' ' not in x, + ) + + dev_requirements = fields.List( + fields.String( + strict=True, + required=True, + validate=lambda x: '=' in x, + error_messages=dict(validator_failed='Version must be specified'), + ), + strict=True, + required=False, + ) + + +class SMetaYaml(Schema): + """Contain the representable of a complete meta.yaml file. + + Schema for a meta.yaml file to be used for cenv. + Ensures the meta.yaml to load contains the relevant information about + the package, source, build, requirements and extra. + The test-section is optional. + """ + + package = fields.Nested( + SNPackage, + strict=True, + required=True, + ) + source = fields.Nested( + SNSource, + strict=True, + required=True, + ) + build = fields.Nested( + SNBuild, + strict=True, + required=True, + ) + requirements = fields.Nested( + SNRequirements, + strict=True, + required=True, + ) + test = fields.Nested( + SNTest, + strict=True, + required=False, + ) + extra = fields.Nested( + SNExtra, + strict=True, + required=True, + ) diff --git a/cenv_tool/utils.py b/cenv_tool/utils.py new file mode 100644 index 0000000..e10cf7c --- /dev/null +++ b/cenv_tool/utils.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +"""Contain utils required by cenv-tool.""" +import os +from pathlib import Path +from subprocess import CalledProcessError +from subprocess import check_output +from subprocess import STDOUT +from typing import NoReturn + +import jinja2 +import six +from marshmallow import ValidationError +from ruamel.yaml import YAML + +from cenv_tool.schemata import SMetaYaml + +CYAN = '\033[1;36m' +GREEN = '\033[1;32m' +RED = '\033[1;91m' +NCOLOR = '\033[0m' +BOLD = '\033[1;37m' + + +class CenvProcessError(Exception): + """Represent a process error during cenv execution.""" + + +def message( + *, text: str, color: str, special: str = None, indent: int = 1 +) -> NoReturn: + """Print the passed text in the passed color on terminal. + + Parameters: + text: The text to print colored on terminal + + """ + color_mapping = { + 'red': RED, + 'green': GREEN, + 'cyan': CYAN, + 'bold': BOLD, + } + if indent == 1: + indent_prefix = ' ' * indent + else: + indent_prefix = ' ' + '│  ' * (indent - 1) + special_mapping = { + 'row': f'{indent_prefix}├── ', + 'end': f'{indent_prefix}└── ', + } + + if special: + prefix = special_mapping[special] + else: + prefix = '' + print(f'{prefix}{color_mapping[color]}{text}{NCOLOR}') + + +def run_in_bash(cmd: str) -> str: + """Run passed cmd inside bash using the subprocess.check_output-function. + + Parameters: + cmd: the command to execute. + + Returns: + the output of the ran command. + + """ + try: + result = check_output([cmd], shell=True, stderr=STDOUT) + except CalledProcessError: + raise CenvProcessError() + return result.strip().decode('ascii') + + +class NullUndefined(jinja2.Undefined): + """Handle jinja2-variables with undefined content inside the meta.yaml.""" + + def __unicode__(self): + """Replace uncode dunder of this class.""" + return six.text_type(self._undefined_name) + + def __getattr__(self, attribute_name: str): + """Replace getattr dunder of this class.""" + return six.text_type(f'{self}.{attribute_name}') + + def __getitem__(self, attribute_name: str): + """Replace getitem dunder of this class.""" + return f'{self}["{attribute_name}"]' + + +class StrDict(dict): + """Handle dictionaries for jinja2-variables inside the meta.yaml.""" + + def __getitem__(self, key: str, default: str = '') -> str: + """Replace getitem dunder of this class.""" + return self[key] if key in self else default + + +def read_meta_yaml(path: Path) -> dict: + """Read the meta.yaml file. + + The file is read from relative path conda-build/meta.yaml inside + the current path, validates the meta.yaml using the marshmallow-schema, + extracts the dependency-information and the project-settings and returns + these information. + + Parameters: + path: The current working directory + + Returns: + List containing the project-settings as a dict and the dependencies + also as a dict + + """ + # load the meta.yaml-content + myaml_content = (path / 'conda-build/meta.yaml').open().read() + jinja2_env = jinja2.Environment(undefined=NullUndefined) + jinja2_loaded_myaml = jinja2_env.from_string(myaml_content) + render_kwargs = { + 'os': os, + 'environ': StrDict(), + 'load_setup_py_data': StrDict, + } + rendered_myaml = jinja2_loaded_myaml.render(**render_kwargs) + loaded_myaml = YAML(typ='base').load(rendered_myaml) + + # validate the content of loaded meta.yaml + try: + dumped = SMetaYaml(strict=True).dumps(loaded_myaml).data + meta_yaml_content = SMetaYaml(strict=True).loads(dumped).data + except ValidationError as err: + message(text='meta.yaml file is not valid!', color='red') + message(text=f'ValidationError in {err.args[0]}', color='red') + raise + + # extract the dependencies defined the the requirements-run-section + dependencies = meta_yaml_content['requirements']['run'] + if meta_yaml_content['extra'].get('dev_requirements'): + dependencies.extend(meta_yaml_content['extra']['dev_requirements']) + + # combine the collected project-settings and the collected dependencies + # to one output of this function + return meta_yaml_content, dependencies + + +def read_config(): + """Read the config file for cenv from the users-home path if it exists. + + If there is no user-config-file the default one is used. + + Returns: + the content of the read config file. + + """ + user_config_path = Path.home() / '.config/cenv/cenv.yml' + default_config_path = Path(__file__).parent / 'cenv.yml' + + # Collect settings from config file .cenv.yml + main_config = YAML(typ='safe').load(default_config_path.open().read()) + + # if a user-config-file exists, read the content and update the main-config + if user_config_path.exists(): + user_config = YAML(typ='safe').load(user_config_path.open().read()) + main_config.update(user_config) + + return main_config diff --git a/docs/about.md b/docs/about.md new file mode 100644 index 0000000..f69e513 --- /dev/null +++ b/docs/about.md @@ -0,0 +1,3 @@ +* **Author**: Simon Kallfass +* **Homepage**: https://www.ouroboros.info +* **Email**: skallfass@ouroboros.info diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..7dc99ed --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,19 @@ +`cenv` uses the path `/opt/conda` as default conda-installation-folder +and `/shared/conda/envs` as default conda-environments-folder. + +You can overwrite these settings with a `cenv.yml` at +`~/.config/cenv/cenv.yml` with the following content: + +```yaml +conda_folder: /opt/conda +env_folder: /shared/conda/envs +export_environment_yml: false +``` + +There you can define your own conda-installation-path and the +conda-environments-folder. +The functionality to export the created / updated environment into +a `environment.yml` can be activated / deactivated here, too. +Per default it is deactivated. +If this is activated, the environment.yml will be placed at +`conda-build/environment.yml`. diff --git a/docs/img/coverage.svg b/docs/img/coverage.svg new file mode 100644 index 0000000..1c7007c --- /dev/null +++ b/docs/img/coverage.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + coverage + coverage + 89% + 89% + + diff --git a/docs/img/logo.png b/docs/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9d4447f583706e684b0e3c657b13376969d13de8 GIT binary patch literal 44298 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 O0000 If you use cenv, each environment should only be created, updated and +> modified using `cenv`! +> This means the commands `conda install`, `conda remove` are not used +> anymore. +> Changes of the dependencies of the environment are defined inside the +> `meta.yaml` and are applied by using `cenv`. +> +> This means: +> +> * new dependency required => add it in `meta.yaml` and run `cenv`. +> * dependency not needed anymore => remove it from `meta.yaml` and run +> `cenv`. +> * need of another version of dependency => change the version of dependency +> in `meta.yaml` and run `cenv`. + +The required information about the projects conda environment are extracted +from the meta.yaml. +This meta.yaml should be located inside the project folder at +`./conda-build/meta.yaml`. +The project-configuration is defined in the `extra` section of the `meta.yaml`. +There you can define the name of the projects conda-environment at +`env_name`. +Also you can define requirements only needed during development but not to be +included into the resulting conda package. +These requirements have to be defined in the `dev_requirements`-section. + +All other parts of the `meta.yaml` have to be defined as default. + +A meta.yaml valid for cenv should look like the following: +```yaml + {% set data = load_setup_py_data() %} + + package: + name: "example_package" + version: {{ data.get("version") }} + + source: + path: .. + + build: + build: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} + preserve_egg_dir: True + script: python -m pip install --no-deps --ignore-installed . + + requirements: + build: + - python 3.6.8 + - pip + - setuptools + run: + - python 3.6.8 + - attrs >=18.2 + - jinja2 >=2.10 + - ruamel.yaml >=0.15.23 + - six >=1.12.0 + - yaml >=0.1.7 + - marshmallow >=3.0.0rc1* + + test: + imports: + - example_package + + extra: + env_name: example + dev_requirements: + - ipython >=7 +``` + +**ATTENTION**: +> In the `requirements-run-section` the minimal version of each package +> has to be defined! +> The same is required for the `dev_requirements`-section. +> Not defining a version will not create or update a conda-environment, +> because this is not the purpose of the conda-usage. +> The validity of the `meta.yaml` is checked in `cenv` using the +> `marshmallow` package. +> You can additionally add upper limits for the version like the following: +> `- package >=0.1,<0.3` + +If cenv is run the environment is created / updated from the definition inside +this `meta.yaml`. +The creation of the backup of the previous environment ensures to undo changes +if any error occurs during recreation of the environment. + + +**ATTENTION**: +> `cenv` can only update the environment if it is not activated. +> So ensure the environment to be deactivated before running `cenv`. + +Per default exporting the conda environment definition into an environment.yml +is turned off. +If you want to turn this functionality on you need to modify your +`~/.config/cenv.yml` as described in [configuration](configuration.md). + +Example for the output of the `cenv` command: + +```bash + ┣━━ Cloning existing env as backup ... + ┣━━ Removing existing env ... + ┣━━ Creating env ... + ┣━━ Removing backup ... + ┗━━ Exporting env to environment.yml ... +``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..7049af5 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,13 @@ +site_name: 'conda-env-manager: cenv' +nav: + - Home: index.md + - Installation: installation.md + - Configuration: configuration.md + - Usage: usage.md + - About: about.md + - License: license.md + - Impressum: impressum.md +theme: + name: readthedocs + hljs_languages: + - yaml diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..644af77 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,563 @@ +[[package]] +category = "dev" +description = "Disable App Nap on OS X 10.9" +marker = "sys_platform == \"darwin\"" +name = "appnope" +optional = false +python-versions = "*" +version = "0.1.0" + +[[package]] +category = "dev" +description = "An abstract syntax tree for Python with inference support." +name = "astroid" +optional = false +python-versions = ">=3.4.*" +version = "2.2.5" + +[package.dependencies] +lazy-object-proxy = "*" +six = "*" +typed-ast = ">=1.3.0" +wrapt = "*" + +[[package]] +category = "dev" +description = "Atomic file writes." +name = "atomicwrites" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.3.0" + +[[package]] +category = "main" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.1.0" + +[[package]] +category = "dev" +description = "Specifications for callback functions passed in to an API" +name = "backcall" +optional = false +python-versions = "*" +version = "0.1.0" + +[[package]] +category = "dev" +description = "Composable command line interface toolkit" +name = "click" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "7.0" + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.4.1" + +[[package]] +category = "dev" +description = "Code coverage measurement for Python" +name = "coverage" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" +version = "4.5.4" + +[[package]] +category = "dev" +description = "Generate coverage badges for Coverage.py." +name = "coverage-badge" +optional = false +python-versions = "*" +version = "1.0.1" + +[[package]] +category = "dev" +description = "Better living through Python with decorators" +name = "decorator" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "4.4.0" + +[[package]] +category = "dev" +description = "Read metadata from Python packages" +name = "importlib-metadata" +optional = false +python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" +version = "0.19" + +[package.dependencies] +zipp = ">=0.5" + +[[package]] +category = "dev" +description = "IPython: Productive Interactive Computing" +name = "ipython" +optional = false +python-versions = ">=3.5" +version = "7.7.0" + +[package.dependencies] +appnope = "*" +backcall = "*" +colorama = "*" +decorator = "*" +jedi = ">=0.10" +pexpect = "*" +pickleshare = "*" +prompt-toolkit = ">=2.0.0,<2.1.0" +pygments = "*" +setuptools = ">=18.5" +traitlets = ">=4.2" + +[[package]] +category = "dev" +description = "Vestigial utilities from IPython" +name = "ipython-genutils" +optional = false +python-versions = "*" +version = "0.2.0" + +[[package]] +category = "dev" +description = "A Python utility / library to sort Python imports." +name = "isort" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "4.3.21" + +[[package]] +category = "dev" +description = "An autocompletion tool for Python that can be used for text editors." +name = "jedi" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.14.1" + +[package.dependencies] +parso = ">=0.5.0" + +[[package]] +category = "main" +description = "A small but fast and easy to use stand-alone template engine written in pure python." +name = "jinja2" +optional = false +python-versions = "*" +version = "2.10.1" + +[package.dependencies] +MarkupSafe = ">=0.23" + +[[package]] +category = "dev" +description = "A fast and thorough lazy object proxy." +name = "lazy-object-proxy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.1" + +[[package]] +category = "dev" +description = "Python LiveReload is an awesome tool for web developers" +name = "livereload" +optional = false +python-versions = "*" +version = "2.6.1" + +[package.dependencies] +six = "*" +tornado = "*" + +[[package]] +category = "dev" +description = "Python implementation of Markdown." +name = "markdown" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +version = "3.1.1" + +[package.dependencies] +setuptools = ">=36" + +[[package]] +category = "main" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + +[[package]] +category = "main" +description = "A lightweight library for converting complex datatypes to and from native Python datatypes." +name = "marshmallow" +optional = false +python-versions = "*" +version = "2.19.5" + +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "dev" +description = "Project documentation with Markdown." +name = "mkdocs" +optional = false +python-versions = ">=2.7.9,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.0.4" + +[package.dependencies] +Jinja2 = ">=2.7.1" +Markdown = ">=2.3.1" +PyYAML = ">=3.10" +click = ">=3.3" +livereload = ">=2.5.1" +tornado = ">=5.0" + +[[package]] +category = "dev" +description = "Generating type annotations from sampled production types" +name = "monkeytype" +optional = false +python-versions = ">=3.6" +version = "19.5.0" + +[package.dependencies] +retype = "*" + +[[package]] +category = "dev" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = false +python-versions = ">=3.4" +version = "7.2.0" + +[[package]] +category = "dev" +description = "A Python Parser" +name = "parso" +optional = false +python-versions = "*" +version = "0.5.1" + +[[package]] +category = "dev" +description = "Pexpect allows easy control of interactive console applications." +marker = "sys_platform != \"win32\"" +name = "pexpect" +optional = false +python-versions = "*" +version = "4.7.0" + +[package.dependencies] +ptyprocess = ">=0.5" + +[[package]] +category = "dev" +description = "Tiny 'shelve'-like database with concurrency support" +name = "pickleshare" +optional = false +python-versions = "*" +version = "0.7.5" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.12.0" + +[package.dependencies] +importlib-metadata = ">=0.12" + +[[package]] +category = "dev" +description = "Library for building powerful interactive command lines in Python" +name = "prompt-toolkit" +optional = false +python-versions = "*" +version = "2.0.9" + +[package.dependencies] +six = ">=1.9.0" +wcwidth = "*" + +[[package]] +category = "dev" +description = "Run a subprocess in a pseudo terminal" +marker = "sys_platform != \"win32\"" +name = "ptyprocess" +optional = false +python-versions = "*" +version = "0.6.0" + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.8.0" + +[[package]] +category = "dev" +description = "Create Python API documentation in Markdown format" +name = "pydoc-markdown" +optional = false +python-versions = "*" +version = "2.0.5" + +[package.dependencies] +Markdown = ">=2.6.11" +MkDocs = ">=0.16.0" +PyYAML = ">=3.12" +six = ">=0.11.0" + +[[package]] +category = "dev" +description = "Pygments is a syntax highlighting package written in Python." +name = "pygments" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "2.4.2" + +[[package]] +category = "dev" +description = "python code static checker" +name = "pylint" +optional = false +python-versions = ">=3.4.*" +version = "2.3.1" + +[package.dependencies] +astroid = ">=2.2.0,<3" +colorama = "*" +isort = ">=4.2.5,<5" +mccabe = ">=0.6,<0.7" + +[[package]] +category = "dev" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.10.1" + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +more-itertools = ">=4.0.0" +pluggy = ">=0.7" +py = ">=1.5.0" +setuptools = "*" +six = ">=1.10.0" + +[[package]] +category = "dev" +description = "Pytest plugin for measuring coverage." +name = "pytest-cov" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.7.1" + +[package.dependencies] +coverage = ">=4.4" +pytest = ">=3.6" + +[[package]] +category = "dev" +description = "py.test plugin to create a 'tmpdir' containing predefined files/directories." +name = "pytest-datafiles" +optional = false +python-versions = "*" +version = "2.0" + +[package.dependencies] +py = "*" +pytest = ">=3.6" + +[[package]] +category = "dev" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "5.1.2" + +[[package]] +category = "dev" +description = "Re-apply types from .pyi stub files to your codebase." +name = "retype" +optional = false +python-versions = "*" +version = "17.12.0" + +[package.dependencies] +click = "*" +typed-ast = "*" + +[[package]] +category = "main" +description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip preservation of comments, seq/map flow style, and map key order" +name = "ruamel.yaml" +optional = false +python-versions = "*" +version = "0.16.1" + +[package.dependencies] +[package.dependencies."ruamel.yaml.clib"] +python = "<3.8" +version = ">=0.1.2" + +[[package]] +category = "main" +description = "C version of reader, parser and emitter for ruamel.yaml derived from libyaml" +marker = "platform_python_implementation == \"CPython\" and python_version < \"3.8\"" +name = "ruamel.yaml.clib" +optional = false +python-versions = "*" +version = "0.1.2" + +[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*" +version = "1.12.0" + +[[package]] +category = "dev" +description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +name = "tornado" +optional = false +python-versions = ">= 3.5" +version = "6.0.3" + +[[package]] +category = "dev" +description = "Traitlets Python config system" +name = "traitlets" +optional = false +python-versions = "*" +version = "4.3.2" + +[package.dependencies] +decorator = "*" +ipython-genutils = "*" +six = "*" + +[[package]] +category = "dev" +description = "a fork of Python 2 and 3 ast modules with type comment support" +name = "typed-ast" +optional = false +python-versions = "*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Measures number of Terminal column cells of wide-character codes" +name = "wcwidth" +optional = false +python-versions = "*" +version = "0.1.7" + +[[package]] +category = "dev" +description = "Module for decorators, wrappers and monkey patching." +name = "wrapt" +optional = false +python-versions = "*" +version = "1.11.2" + +[[package]] +category = "dev" +description = "A formatter for Python code." +name = "yapf" +optional = false +python-versions = "*" +version = "0.28.0" + +[[package]] +category = "dev" +description = "Backport of pathlib-compatible object wrapper for zip files" +name = "zipp" +optional = false +python-versions = ">=2.7" +version = "0.5.2" + +[extras] +docs = [] + +[metadata] +content-hash = "e22c73191a4da9993606ee107a024cdfb23a4a67294465ee622ee2d2ee961feb" +python-versions = "^3.7" + +[metadata.hashes] +appnope = ["5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0", "8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"] +astroid = ["6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", "b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4"] +atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] +attrs = ["69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", "f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"] +backcall = ["38ecd85be2c1e78f77fd91700c76e14667dc21e2713b63876c0eb901196e01e4", "bbbf4b1e5cd2bdb08f915895b51081c041bac22394fdfcfdfbe9f14b77c08bf2"] +click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] +colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] +coverage = ["08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", "0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", "141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", "19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", "23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", "245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", "331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", "386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", "3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", "60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", "63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", "6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", "6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", "7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", "826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", "93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", "9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", "af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", "bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", "bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", "c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", "dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", "df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", "e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", "e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", "e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", "eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", "eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", "ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", "efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", "fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"] +coverage-badge = ["142fd121f3bd14956aff3c45bff6f8bc37bd74c6350626a950ebb6accb24276e", "3796de21b4e190d38beb8806956946fbdb02fe3a2a7452b460a9cff958009833"] +decorator = ["86156361c50488b84a3f148056ea716ca587df2f0de1d34750d35c21312725de", "f069f3a01830ca754ba5258fde2278454a0b5b79e0d7f5c13b3b97e57d4acff6"] +importlib-metadata = ["23d3d873e008a513952355379d93cbcab874c58f4f034ff657c7a87422fa64e8", "80d2de76188eabfbfcf27e6a37342c2827801e59c4cc14b0371c56fed43820e3"] +ipython = ["1d3a1692921e932751bc1a1f7bb96dc38671eeefdc66ed33ee4cbc57e92a410e", "537cd0176ff6abd06ef3e23f2d0c4c2c8a4d9277b7451544c6cbf56d1c79a83d"] +ipython-genutils = ["72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8", "eb2e116e75ecef9d4d228fdc66af54269afa26ab4463042e33785b887c628ba8"] +isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] +jedi = ["53c850f1a7d3cfcd306cc513e2450a54bdf5cacd7604b74e42dd1f0758eaaf36", "e07457174ef7cb2342ff94fa56484fe41cec7ef69b0059f01d3f812379cb6f7c"] +jinja2 = ["065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", "14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b"] +lazy-object-proxy = ["159a745e61422217881c4de71f9eafd9d703b93af95618635849fe469a283661", "23f63c0821cc96a23332e45dfaa83266feff8adc72b9bcaef86c202af765244f", "3b11be575475db2e8a6e11215f5aa95b9ec14de658628776e10d96fa0b4dac13", "3f447aff8bc61ca8b42b73304f6a44fa0d915487de144652816f950a3f1ab821", "4ba73f6089cd9b9478bc0a4fa807b47dbdb8fad1d8f31a0f0a5dbf26a4527a71", "4f53eadd9932055eac465bd3ca1bd610e4d7141e1278012bd1f28646aebc1d0e", "64483bd7154580158ea90de5b8e5e6fc29a16a9b4db24f10193f0c1ae3f9d1ea", "6f72d42b0d04bfee2397aa1862262654b56922c20a9bb66bb76b6f0e5e4f9229", "7c7f1ec07b227bdc561299fa2328e85000f90179a2f44ea30579d38e037cb3d4", "7c8b1ba1e15c10b13cad4171cfa77f5bb5ec2580abc5a353907780805ebe158e", "8559b94b823f85342e10d3d9ca4ba5478168e1ac5658a8a2f18c991ba9c52c20", "a262c7dfb046f00e12a2bdd1bafaed2408114a89ac414b0af8755c696eb3fc16", "acce4e3267610c4fdb6632b3886fe3f2f7dd641158a843cf6b6a68e4ce81477b", "be089bb6b83fac7f29d357b2dc4cf2b8eb8d98fe9d9ff89f9ea6012970a853c7", "bfab710d859c779f273cc48fb86af38d6e9210f38287df0069a63e40b45a2f5c", "c10d29019927301d524a22ced72706380de7cfc50f767217485a912b4c8bd82a", "dd6e2b598849b3d7aee2295ac765a578879830fb8966f70be8cd472e6069932e", "e408f1eacc0a68fed0c08da45f31d0ebb38079f043328dce69ff133b95c29dc1"] +livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"] +markdown = ["2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a", "56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"] +markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] +marshmallow = ["9cedfc5b6f568d57e8a2cf3d293fbd81b05e5ef557854008d03e25660a39ccfd", "a4d99922116a76e5abd8f997ec0519086e24814b7e1e1344bebe2a312ba50235"] +mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] +mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"] +monkeytype = ["67c8a5694fbc78b3c763eccca834bcbc0a7964969fa467f78e43a4141d650787", "adb86d4dd4760e80e9670179ab09b09b4e3765115e31f9f2840c1ef3ebc91167"] +more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] +parso = ["63854233e1fadb5da97f2744b6b24346d2750b85965e7e399bec1620232797dc", "666b0ee4a7a1220f65d367617f2cd3ffddff3e205f3f16a0284df30e774c2a9c"] +pexpect = ["2094eefdfcf37a1fdbfb9aa090862c1a4878e5c7e0e7e7088bdb511c558e5cd1", "9e2c1fd0e6ee3a49b28f95d4b33bc389c89b20af6a1255906e90ff1262ce62eb"] +pickleshare = ["87683d47965c1da65cdacaf31c8441d12b8044cdec9aca500cd78fc2c683afca", "9649af414d74d4df115d5d718f82acb59c9d418196b7b4290ed47a12ce62df56"] +pluggy = ["0825a152ac059776623854c1543d65a4ad408eb3d33ee114dff91e57ec6ae6fc", "b9817417e95936bf75d85d3f8767f7df6cdde751fc40aed3bb3074cbcb77757c"] +prompt-toolkit = ["11adf3389a996a6d45cc277580d0d53e8a5afd281d0c9ec71b28e6f121463780", "2519ad1d8038fd5fc8e770362237ad0364d16a7650fb5724af6997ed5515e3c1", "977c6583ae813a37dc1c2e1b715892461fcbdaa57f6fc62f33a528c4886c8f55"] +ptyprocess = ["923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", "d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f"] +py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] +pydoc-markdown = ["f1511ad5a6ca33a52dd2010651d18e9debb57bcf36ab87eca560dd15a753ce1f"] +pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] +pylint = ["5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", "723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1"] +pytest = ["3f193df1cfe1d1609d4c583838bea3d532b18d6160fd3f55c9447fdca30848ec", "e246cf173c01169b9617fc07264b7b1316e78d7a650055235d6d897bc80d9660"] +pytest-cov = ["2b097cde81a302e1047331b48cadacf23577e431b61e9c6f49a1170bbe3d3da6", "e00ea4fdde970725482f1f35630d12f074e121a23801aabf2ae154ec6bdd343a"] +pytest-datafiles = ["143329cbb1dbbb07af24f88fa4668e2f59ce233696cf12c49fd1c98d1756dbf9", "e349b6ad7bcca111f3677b7201d3ca81f93b5e09dcfae8ee2be2c3cae9f55bc7"] +pyyaml = ["0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"] +retype = ["33cfb36601bfeb355924731d8db78fa82f3f12eb37e87236e9179d81aba97740", "b64b767befbe6f5fd918603ab7d6bbff07fc4c431bae2f471e195677a0c9b327"] +"ruamel.yaml" = ["547aeab5c51c93bc750ed2a320c1559b605bde3aa569216aa75fd91d8a1c4623"] +"ruamel.yaml.clib" = ["0bbe19d3e099f8ba384e1846e6b54f245f58aeec8700edbbf9abb87afa54fd82", "2f38024592613f3a8772bbc2904be027d9abf463518ba145f2d0c8e6da27009f", "44449b3764a3f75815eea8ae5930b98e8326be64a90b0f782747318f861abfe0", "5710be9a357801c31c1eaa37b9bc92d38176d785af5b2f0c9751385c5dc9659a", "5a089acb6833ed5f412e24cbe3e665683064c1429824d2819137b5ade54435c3", "6143386ddd61599ea081c012a69a16e5bdd7b3c6c231bd039534365a48940f30", "6726aaf851f5f9e4cbdd3e1e414bc700bdd39220e8bc386415fd41c87b1b53c2", "68fbc3b5d94d145a391452f886ae5fca240cb7e3ab6bd66e1a721507cdaac28a", "75ebddf99ba9e0b48f32b5bdcf9e5a2b84c017da9e0db7bf11995fa414aa09cd", "79948a6712baa686773a43906728e20932c923f7b2a91be7347993be2d745e55", "8a2dd8e8b08d369558cade05731172c4b5e2f4c5097762c6b352bd28fd9f9dc4", "c747acdb5e8c242ab2280df6f0c239e62838af4bee647031d96b3db2f9cefc04", "cadc8eecd27414dca30366b2535cb5e3f3b47b4e2d6be7a0b13e4e52e459ff9f", "cee86ecc893a6a8ecaa7c6a9c2d06f75f614176210d78a5f155f8e78d6989509", "e59af39e895aff28ee5f55515983cab3466d1a029c91c04db29da1c0f09cf333", "eee7ecd2eee648884fae6c51ae50c814acdcc5d6340dc96c970158aebcd25ac6", "ef8d4522d231cb9b29f6cdd0edc8faac9d9715c60dc7becbd6eb82c915a98e5b", "f504d45230cc9abf2810623b924ae048b224a90adb01f97db4e766cfdda8e6eb"] +six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] +tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"] +traitlets = ["9c4bd2d267b7153df9152698efb1050a5d84982d3384a37b2c1f7723ba3e7835", "c6cb5e6f57c5a9bdaa40fa71ce7b4af30298fbab9ece9815b5d995ab6217c7d9"] +typed-ast = ["18511a0b3e7922276346bcb47e2ef9f38fb90fd31cb9223eed42c85d1312344e", "262c247a82d005e43b5b7f69aff746370538e176131c32dda9cb0f324d27141e", "2b907eb046d049bcd9892e3076c7a6456c93a25bebfe554e931620c90e6a25b0", "354c16e5babd09f5cb0ee000d54cfa38401d8b8891eefa878ac772f827181a3c", "4e0b70c6fc4d010f8107726af5fd37921b666f5b31d9331f0bd24ad9a088e631", "630968c5cdee51a11c05a30453f8cd65e0cc1d2ad0d9192819df9978984529f4", "66480f95b8167c9c5c5c87f32cf437d585937970f3fc24386f313a4c97b44e34", "71211d26ffd12d63a83e079ff258ac9d56a1376a25bc80b1cdcdf601b855b90b", "95bd11af7eafc16e829af2d3df510cecfd4387f6453355188342c3e79a2ec87a", "bc6c7d3fa1325a0c6613512a093bc2a2a15aeec350451cbdf9e1d4bffe3e3233", "cc34a6f5b426748a507dd5d1de4c1978f2eb5626d51326e43280941206c209e1", "d755f03c1e4a51e9b24d899561fec4ccaf51f210d52abdf8c07ee2849b212a36", "d7c45933b1bdfaf9f36c579671fec15d25b06c8398f113dab64c18ed1adda01d", "d896919306dd0aa22d0132f62a1b78d11aaf4c9fc5b3410d3c666b818191630a", "ffde2fbfad571af120fcbfbbc61c72469e72f550d676c3342492a9dfdefb8f12"] +wcwidth = ["3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", "f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"] +wrapt = ["565a021fd19419476b9362b05eeaa094178de64f8361e44468f9e9d7843901e1"] +yapf = ["02ace10a00fa2e36c7ebd1df2ead91dbfbd7989686dc4ccbdc549e95d19f5780", "6f94b6a176a7c114cfa6bad86d40f259bbe0f10cf2fa7f2f4b3596fc5802a41b"] +zipp = ["4970c3758f4e89a7857a973b1e2a5d75bcdc47794442f2e2dd4fe8e0466e809a", "8a5712cfd3bb4248015eb3b0b3c54a5f6ee3f2425963ef2a0125b8bc40aafaec"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7552553 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[tool.poetry] +name = "cenv_tool" +version = "1.0.0" +description = "conda environment creation and update from meta.yaml" +license = "MIT" +authors = ["Simon Kallfass "] +readme = "README.md" +homepage = "https://www.cenv.ouroboros.info" +keywords = ["conda", "environment", "dependencies"] + + +[tool.poetry.dependencies] +python = "^3.7" +attrs = "~19" +jinja2 = ">=2" +"ruamel.yaml" = ">=0.15" +six = ">=1.12" +marshmallow = ">=2.19,<3" + + +[tool.poetry.dev-dependencies] +coverage-badge = "~1" +ipython = ">=7" +mkdocs = "~1" +monkeytype = ">=19" +pydoc-markdown = "~2" +pylint = ">=2" +pytest = "^3.0" +pytest-cov = '~2' +pytest-datafiles = '~2' +yapf = ">=0" + + +[tool.poetry.scripts] +cenv = "cenv_tool.project:main" +init_cenv = "cenv_tool.init_cenv:main" + + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" + + +[tool.poetry.extras] +docs = ["mkdocs", "pydoc-markdown"] + + +[tool.dephell.main] +# read from poetry format +from = {format = "poetry", path = "pyproject.toml"} +# drop dev-dependencies +envs = ["main"] +# and convert into setup.py +to = {format = "setuppy", path = "setup.py"} +versioning = "semver" + + +[tool.dephell.lock] +from = {format = "poetry", path = "pyproject.toml"} +to = {format = "poetrylock", path = "poetry.lock"} + + +[tool.dephell.docs] +# read dependencies from poetry format +from = {format = "poetry", path = "pyproject.toml"} +# install only `docs` extra dependencies +envs = ["docs"] +# run this command: +command = "mkdocs build" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..01fea58 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,61 @@ +[tool:pytest] +addopts = --cov=cenv_tool + --cov-report html + --cov-report term-missing:skip-covered + --cov-config=setup.cfg + + +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + if __name__ == .__main__.: + def main + def script_options + +[pydocstyle] +convention = google + +[flake8] +max-line-length = 100 + +[yapf] +align_closing_bracket_with_visual_indent=False +allow_multiline_dictionary_keys=False +allow_multiline_lambdas=False +allow_split_before_default_or_named_assigns=True +allow_split_before_dict_value=True +arithmetic_precedence_indication=False +blank_lines_around_top_level_definition=2 +blank_line_before_class_docstring=False +blank_line_before_module_docstring=False +blank_line_before_nested_class_or_def=True +coalesce_brackets=False +column_limit=80 +continuation_align_style=SPACE +continuation_indent_width=4 +dedent_closing_brackets=True +disable_ending_comma_heuristic=False +each_dict_entry_on_separate_line=True +i18n_comment=#\..* +i18n_function_call=N_, _ +indent_blank_lines=False +indent_dictionary_value=False +indent_width=4 +join_multiple_lines=True +spaces_around_default_or_named_assign=False +spaces_around_power_operator=False +spaces_before_comment=2 +space_between_ending_comma_and_closing_bracket=False +split_all_comma_separated_values=False +split_arguments_when_comma_terminated=False +split_before_arithmetic_operator=False +split_before_bitwise_operator=False +split_before_closing_bracket=True +split_before_dict_set_generator=False +split_before_dot=False +split_before_expression_after_opening_paren=False +split_before_first_argument=False +split_before_logical_operator=False +split_before_named_assigns=True +split_complex_comprehension=True diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..467effa --- /dev/null +++ b/setup.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- + +# DO NOT EDIT THIS FILE! +# This file has been autogenerated by dephell <3 +# https://github.com/dephell/dephell + +try: + from setuptools import setup +except ImportError: + from distutils.core import setup + +import os.path + +readme = '' +here = os.path.abspath(os.path.dirname(__file__)) +readme_path = os.path.join(here, 'README.rst') +if os.path.exists(readme_path): + with open(readme_path, 'rb') as stream: + readme = stream.read().decode('utf8') + +setup( + long_description=readme, + name='cenv_tool', + version='1.0.0', + description='conda environment creation and update from meta.yaml', + python_requires='==3.*,>=3.7.0', + project_urls={'homepage': 'https://www.cenv.ouroboros.info'}, + author='Simon Kallfass', + author_email='skallfass@ouroboros.info', + license='MIT', + keywords='conda environment dependencies', + entry_points={ + 'console_scripts': [ + 'cenv = cenv_tool.project:main', + 'init_cenv = cenv_tool.init_cenv:main' + ] + }, + packages=['cenv_tool'], + package_data={'cenv_tool': ['*.sh', '*.yml']}, + install_requires=[ + 'attrs==19.*,>=19.0.0', 'jinja2>=2', 'marshmallow<3,>=2.19', + 'ruamel.yaml>=0.15', 'six>=1.12' + ], + extras_require={ + 'dev': [ + 'coverage-badge==1.*,>=1.0.0', 'ipython>=7', 'mkdocs==1.*,>=1.0.0', + 'monkeytype>=19', 'pydoc-markdown==2.*,>=2.0.0', 'pylint>=2', + 'pytest==3.*,>=3.0.0', 'pytest-cov==2.*,>=2.0.0', + 'pytest-datafiles==2.*,>=2.0.0', 'yapf>=0' + ], + 'docs': ['mkdocs==1.*,>=1.0.0', 'pydoc-markdown==2.*,>=2.0.0'] + }, +) diff --git a/tests/.condarc b/tests/.condarc new file mode 100644 index 0000000..e839621 --- /dev/null +++ b/tests/.condarc @@ -0,0 +1,17 @@ +allow_other_channels: True +show_channel_urls: True + +conda-build: + output-folder: /opt/conda/conda-bld + +pkgs_dirs: + - /shared/conda/pkgs + +envs_dirs: + - /shared/conda/envs + +channels: + - defaults + - local + - https://repository-channel.ouroboros.info + - conda-forge diff --git a/tests/home_test/.bashrc b/tests/home_test/.bashrc new file mode 100644 index 0000000..2ef267e --- /dev/null +++ b/tests/home_test/.bashrc @@ -0,0 +1 @@ +some content diff --git a/tests/home_test/.zshrc b/tests/home_test/.zshrc new file mode 100644 index 0000000..2ef267e --- /dev/null +++ b/tests/home_test/.zshrc @@ -0,0 +1 @@ +some content diff --git a/tests/init_cenv_test.py b/tests/init_cenv_test.py new file mode 100644 index 0000000..d3072be --- /dev/null +++ b/tests/init_cenv_test.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from pathlib import Path + +import pytest + +from cenv_tool.init_cenv import initialize_cenv +from cenv_tool.init_cenv import RC_CONTENT + + +@pytest.mark.datafiles('tests/home_test') +def test_initialize_cenv(datafiles): + expected_result = 'some content\n' + RC_CONTENT + config_path = Path(datafiles) / '.config/cenv' + config_file = config_path / 'cenv.yml' + autoenv_script_path = config_path / 'cenv.sh' + zshrc = Path(datafiles) / '.zshrc' + bashrc = Path(datafiles) / '.bashrc' + for _ in range(2): + initialize_cenv( + config_path=config_path, + autoenv_script_path=autoenv_script_path, + autoenv_script_source_path=Path('cenv_tool/cenv.sh'), + config_file=config_file, + config_file_source=Path('cenv_tool/cenv.yml'), + zshrc=zshrc, + bashrc=bashrc, + ) + assert expected_result == zshrc.read_text() + assert expected_result == bashrc.read_text() + assert autoenv_script_path.exists() + assert config_file.exists() + assert config_path.exists() diff --git a/tests/invalid_testproject/conda-build/meta.yaml b/tests/invalid_testproject/conda-build/meta.yaml new file mode 100644 index 0000000..dc634d0 --- /dev/null +++ b/tests/invalid_testproject/conda-build/meta.yaml @@ -0,0 +1,43 @@ +{% set data = load_setup_py_data() %} + +package: + name: "testproject" + version: {{ data.get("version") }} + +source: + path: .. + +build: + build: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} + preserve_egg_dir: True + script: python -m pip install --no-deps --ignore-installed . + entry_points: + - testproject = testproject.testproject:main + +requirements: + build: + - python 3.7.3 + - pip + - setuptools + run: + - python 3.7.3 + - attrs >=19 + - jinja2 >=2.10 + - ruamel.yaml >=0.15.23 + - six >=1.12.0 + - yaml >=0.1.7 + - marshmallow >=2,<3 + +test: + imports: + - testproject + - testproject.testproject + commands: + - testproject --help + - testproject -v + +extra: + dev_requirements: + - ipython >=7.2.0 + - mkdocs >=1.0.4 + - pylint >=2.2.2 diff --git a/tests/project_test.py b/tests/project_test.py new file mode 100644 index 0000000..91e372f --- /dev/null +++ b/tests/project_test.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +import os +from pathlib import Path + +import pytest + +from cenv_tool.project import Project +from cenv_tool.rules import RULES + + +def test_project_collect_available_envs(): + current_path = Path.cwd() + testfolder = Path('tests/testproject') + os.chdir(str(testfolder)) + project = Project(rules=RULES) + os.chdir(str(current_path)) + available_envs = project.collect_available_envs() + print(available_envs) + + +@pytest.mark.datafiles('tests/testproject') +def test_project_update(datafiles): + created_env = Path('/shared/conda/envs/cenv_testing_project0001') + environment_yml = Path(datafiles) / 'conda-build/environment.yml' + current_folder = Path.cwd() + os.chdir(datafiles) + project = Project(rules=RULES) + project.update() + assert created_env.exists() + project = Project(rules=RULES) + project.update() + assert created_env.exists() + project = Project(rules=RULES) + project.remove_previous_environment() + project.remove_backup_environment() + project.create_environment(cloned=False) + project.export_environment_definition() + assert environment_yml.exists() + environment_yml.unlink() + project.remove_previous_environment() + project.remove_backup_environment() + os.chdir(str(current_folder)) diff --git a/tests/rules_test.py b/tests/rules_test.py new file mode 100644 index 0000000..25ab753 --- /dev/null +++ b/tests/rules_test.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +"""Test the rules module of cenv.""" +from cenv_tool.rules import RULES + + +def test_rules(): + """Test if RULES contain attributes as expected.""" + assert RULES.git_folder + assert RULES.conda_cmds + assert RULES.conda_cmds.remove + assert RULES.conda_cmds.export + assert RULES.conda_cmds.create + assert RULES.conda_cmds.clone + assert RULES.conda_cmds.restore + assert RULES.conda_cmds.clean diff --git a/tests/testproject/conda-build/meta.yaml b/tests/testproject/conda-build/meta.yaml new file mode 100644 index 0000000..966901d --- /dev/null +++ b/tests/testproject/conda-build/meta.yaml @@ -0,0 +1,39 @@ +{% set data = load_setup_py_data() %} + +package: + name: "cenv_testing_project0001" + version: {{ data.get("version") }} + +source: + path: .. + +build: + build: {{ environ.get('GIT_DESCRIBE_NUMBER', 0) }} + preserve_egg_dir: True + script: python -m pip install --no-deps --ignore-installed . + entry_points: + - testproject = testproject.testproject:main + +requirements: + build: + - python 3.7.3 + - pip + - setuptools + run: + - python 3.7.3 + - attrs >=19 + - jinja2 >=2.10 + - six >=1.12.0 + - yaml >=0.1.7 + +test: + imports: + - cenv_testing_project0001 + commands: + - cenv_testing_project0001 --help + - cenv_testing_project0001 -v + +extra: + env_name: cenv_testing_project0001 + dev_requirements: + - pylint >=2.2.2 diff --git a/tests/utils_test.py b/tests/utils_test.py new file mode 100644 index 0000000..8931748 --- /dev/null +++ b/tests/utils_test.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- +"""Test the utils module of cenv.""" +from pathlib import Path + +import pytest +from marshmallow import ValidationError + +from cenv_tool.utils import CenvProcessError +from cenv_tool.utils import read_meta_yaml +from cenv_tool.utils import run_in_bash + + +@pytest.mark.parametrize( + 'meta_yaml_path, expected_meta_yaml_content, expected_dependencies', + [ + ( + Path('tests/testproject'), + { + 'requirements': { + 'build': ['python 3.7.3', 'pip', 'setuptools'], + 'run': [ + 'python 3.7.3', + 'attrs >=19', + 'jinja2 >=2.10', + 'six >=1.12.0', + 'yaml >=0.1.7', + 'pylint >=2.2.2', + ], + }, + 'extra': { + 'dev_requirements': ['pylint >=2.2.2'], + 'env_name': 'cenv_testing_project0001', + }, + 'source': { + 'path': '..', + }, + 'test': { + 'commands': [ + 'cenv_testing_project0001 --help', + 'cenv_testing_project0001 -v', + ], + 'imports': ['cenv_testing_project0001'], + }, + 'build': { + 'build': '0', + 'preserve_egg_dir': 'True', + 'script': + 'python -m pip install --no-deps --ignore-installed .', + 'entry_points': + ['testproject = testproject.testproject:main'], + }, + 'package': { + 'version': 'None', + 'name': 'cenv_testing_project0001', + }, + }, + [ + 'python 3.7.3', + 'attrs >=19', + 'jinja2 >=2.10', + 'six >=1.12.0', + 'yaml >=0.1.7', + 'pylint >=2.2.2', + ], + ), + ], +) +def test_read_meta_yaml( + meta_yaml_path, + expected_meta_yaml_content, + expected_dependencies, +): + """Test if the read_meta_yaml function works as expected.""" + meta_yaml_content, dependencies = read_meta_yaml(path=meta_yaml_path) + assert meta_yaml_content == expected_meta_yaml_content + assert expected_dependencies == dependencies + + +@pytest.mark.parametrize('meta_yaml_path', [Path('tests/invalid_testproject')]) +def test_read_meta_yaml_fails(meta_yaml_path): + """Test if read_meta_yaml function fails on invalid meta.yaml.""" + with pytest.raises(ValidationError): + read_meta_yaml(path=meta_yaml_path) + + +def test_run_in_bash(): + """Test if run_in_bash works as expected.""" + cmd_result = run_in_bash(cmd='ls tests/testproject/conda-build') + assert cmd_result == 'meta.yaml' + + +def test_run_in_bash_fails(): + """Test if run_in_bash works as expected.""" + with pytest.raises(CenvProcessError): + run_in_bash(cmd='ls-a tests/testproject/conda-build')