Skip to content
This repository has been archived by the owner on Jan 12, 2021. It is now read-only.

Commit

Permalink
Merge pull request #248 from dephell/env-vars-config
Browse files Browse the repository at this point in the history
Configure DepHell by environment variables
  • Loading branch information
orsinium committed Jul 24, 2019
2 parents 8234b88 + 6fed133 commit 49e9415
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 7 deletions.
14 changes: 12 additions & 2 deletions dephell/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import re
from argparse import ArgumentParser
from logging import getLogger
from os import environ
from pathlib import Path
from typing import Dict, Set

Expand All @@ -12,7 +13,7 @@
# app
from ..actions import attach_deps, get_python_env
from ..config import Config, config, get_data_dir
from ..constants import CONFIG_NAMES, GLOBAL_CONFIG_NAME
from ..constants import CONFIG_NAMES, ENV_VAR_TEMPLATE, GLOBAL_CONFIG_NAME
from ..controllers import analyze_conflict
from ..converters import CONVERTERS, InstalledConverter

Expand All @@ -39,6 +40,7 @@ def get_config(cls, args) -> Config:
config.setup_logging()
cls._attach_global_config_file()
cls._attach_config_file(path=args.config, env=args.env)
config.attach_env_vars()
config.attach_cli(args)
config.setup_logging()
return config
Expand Down Expand Up @@ -84,10 +86,18 @@ def _attach_global_config_file(cls) -> bool:

@classmethod
def _attach_config_file(cls, path, env) -> bool:
# get params from env vars if are not specified
if path is None:
path = environ.get(ENV_VAR_TEMPLATE.format('CONFIG'))
if env is None:
env = environ.get(ENV_VAR_TEMPLATE.format('ENV'), 'main')

# if path to config specified explicitly, just use it
if path:
config.attach_file(path=path, env=env)
return True

# if path isn't specified, carefully try default names
for path in CONFIG_NAMES:
if not os.path.exists(path):
continue
Expand Down Expand Up @@ -136,7 +146,7 @@ def _resolve(self, resolver, default_envs: Set[str] = None):
return None

# apply envs if needed
if 'envs' in self.config:
if self.config.get('envs'):
resolver.apply_envs(set(self.config['envs']))
elif default_envs:
resolver.apply_envs(default_envs)
Expand Down
2 changes: 1 addition & 1 deletion dephell/commands/venv_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def __call__(self) -> bool:
)

# run
self.logger.info('running...')
self.logger.info('running...', extra=dict(command=command))
with override_env_vars(env_vars):
result = subprocess.run([str(executable)] + command[1:])
if result.returncode != 0:
Expand Down
2 changes: 1 addition & 1 deletion dephell/config/builders.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
def build_config(parser):
config_group = parser.add_argument_group('Configuration file')
config_group.add_argument('-c', '--config', help='path to config file.')
config_group.add_argument('-e', '--env', default='main', help='environment in config.')
config_group.add_argument('-e', '--env', help='environment in config.')


def build_from(parser):
Expand Down
40 changes: 38 additions & 2 deletions dephell/config/manager.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
# built-in
import json
import re
from collections import defaultdict
from copy import deepcopy
from logging import captureWarnings
from logging.config import dictConfig
from os import environ
from pathlib import Path
from typing import Dict, Optional

# external
import tomlkit
from cerberus import Validator
from tomlkit.exceptions import TOMLKitError

# app
from ..constants import NON_PATH_FORMATS, SUFFIXES
from ..constants import ENV_VAR_TEMPLATE, NON_PATH_FORMATS, SUFFIXES
from .defaults import DEFAULT
from .logging_config import LOGGING
from .scheme import SCHEME


ENV_VAR_REX = re.compile(ENV_VAR_TEMPLATE.format('(.+)'))


class Config:
env = ''
_skip = (
Expand All @@ -26,7 +33,7 @@ class Config:
)

def __init__(self, data: Optional[dict] = None):
self._data = data or DEFAULT.copy()
self._data = data or deepcopy(DEFAULT)

def setup_logging(self, data: Optional[dict] = None) -> None:
captureWarnings(True)
Expand Down Expand Up @@ -133,6 +140,35 @@ def attach_cli(self, args, sep: str = '_') -> dict:
self.attach(data)
return dict(data)

def attach_env_vars(self, *, env_vars: Dict[str, str] = environ, sep: str = '_') -> dict:
data = defaultdict(dict)
for name, value in env_vars.items():
# drop templated part from name
match = ENV_VAR_REX.fullmatch(name)
if not match:
continue
name = match.groups()[0].lower()
if name in ('env', 'config'):
continue

# convert value to the correct type
try:
value = tomlkit.parse('key={}'.format(value))['key']
except TOMLKitError:
pass

# do the same as in `attach_cli`
parsed = name.split(sep, maxsplit=1)
if len(parsed) == 1:
data[name] = value
else:
# if old content isn't a dict, override it
if not isinstance(data[parsed[0]], dict):
data[parsed[0]] = dict()
data[parsed[0]][parsed[1]] = value
self.attach(data)
return dict(data)

def validate(self) -> bool:
self._data = {k: v for k, v in self._data.items() if k not in self._skip}
validator = Validator(SCHEME)
Expand Down
1 change: 1 addition & 0 deletions dephell/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class ReturnCodes(Enum):

CONFIG_NAMES = ('poetry.toml', 'pyproject.toml')
GLOBAL_CONFIG_NAME = 'config.toml'
ENV_VAR_TEMPLATE = 'DEPHELL_{}'

DEFAULT_WAREHOUSE = 'https://pypi.org/pypi/'
WAREHOUSE_DOMAINS = {'pypi.org', 'pypi.python.org', 'test.pypi.org'}
Expand Down
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Follow [@PythonDepHell](https://twitter.com/PythonDepHell) on Twitter to get upd
## v.0.7.8 (WIP)

+ Fuzzy command name search ([#247](https://github.com/dephell/dephell/pull/247), [#122](https://github.com/dephell/dephell/issues/122)).
+ [Configure](config) DepHell with environment variables ([#248](https://github.com/dephell/dephell/pull/248)).

## v.0.7.7

Expand Down
47 changes: 46 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ Dephell makes config from 3 layers:

1. Default parameters.
1. Section from config file.
1. Environment variables.
1. CLI arguments.

## Config file

Config should be TOML file with `tool.dephell.ENV_NAME` sections (see [PEP-518](https://www.python.org/dev/peps/pep-0518/#tool-table)).
Config should be [TOML](https://github.com/toml-lang/toml) file with `tool.dephell.ENV_NAME` sections (see [PEP-518](https://www.python.org/dev/peps/pep-0518/#tool-table)).

1. By default, dephell tries to read `pyproject.toml` or `dephell.toml`. You can change it by `--config` argument.
1. Default environment: `main`. Environment is the name of the section inside of `tool.dephell` section in config file. You can change environment by `--env` argument.
Expand Down Expand Up @@ -59,6 +60,50 @@ $ dephell venv run --env=pytest

Also, by default, DepHell uses `--env` to generate path to the virtual environment, so different `--env` values have different virtual environments.

## Environment variables

Sometimes, specifying config parameters in environment variables can be more suitable for you. Most common case is to set up `env` or path to config file. For example:

```bash
export DEPHELL_ENV=flake8
export DEPHELL_CONFIG="./project/dephell.toml"

# commands below will be executed with specified above env and path to config
dephell venv create
dephell deps install
dephell venv run

# do not forget to remove variables after all
unset DEPHELL_ENV
unset DEPHELL_CONFIG
```

DepHell do type casting in the same way as [dynaconf](https://dynaconf.readthedocs.io/en/latest/guides/environment_variables.html#precedence-and-type-casting). Just use TOML syntax for values:

```bash
# Numbers
DEPHELL_CACHE_TTL=42
DEPHELL_SDIST_RATIO=0.5

# Text
DEPHELL_FROM_FORMAT=pip
DEPHELL_FROM_FORMAT="pip"

# Booleans
DEPHELL_SILENT=true
DEPHELL_SILENT=false

# Use extra quotes to force a string from other type
DEPHELL_PYTHON="'3.6'"
DEPHELL_PROJECT="'true'"

# Arrays
DEPHELL_ENVS="['main', 'dev']"

# Dictionaries
DEPHELL_FROM='{format="pip", path="req.txt"}'
```

## See also

1. [`inspect config` command](cmd-inspect-config) to discover how dephell makes config for your project.
Expand Down
23 changes: 23 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# built-in
from pathlib import Path

# external
import pytest

# project
from dephell.config import Config

Expand All @@ -9,3 +12,23 @@ def test_load():
config = Config()
config.attach_file(path=str(Path('tests') / 'requirements' / 'dephell.toml'), env='some_env')
assert config['from']['format'] == 'pip'


@pytest.mark.parametrize('given, expected', [
({'DEPHELL_COMMAND': 'pip'}, {'command': 'pip'}),
({'DEPHELL_FROM_FORMAT': 'pip'}, {'from': {'format': 'pip'}}),
({'SOME_JUNK': 'pip'}, {}),
({'DEPHELL_ENV': 'pytest'}, {}),
({'DEPHELL_CACHE_TTL': '10'}, {'cache': {'ttl': 10}}),
({'DEPHELL_CACHE_TTL': '"10"'}, {'cache': {'ttl': '10'}}),
({'DEPHELL_TRACEBACK': 'true'}, {'traceback': True}),
({'DEPHELL_ENVS': '["main", "dev"]'}, {'envs': ['main', 'dev']}),
(
{'DEPHELL_FROM': '{format="pip", path="req.txt"}'},
{'from': {'format': 'pip', 'path': 'req.txt'}},
),
])
def test_attach_env_vars(given, expected):
config = Config()
result = config.attach_env_vars(env_vars=given)
assert result == expected

0 comments on commit 49e9415

Please sign in to comment.