from __future__ import annotations import importlib import json import os import pprint import sys import warnings import webbrowser from contextlib import suppress from pathlib import Path from dynaconf import constants from dynaconf import default_settings from dynaconf import LazySettings from dynaconf import loaders from dynaconf import settings as legacy_settings from dynaconf.loaders.py_loader import get_module from dynaconf.utils import upperfy from dynaconf.utils.files import read_file from dynaconf.utils.functional import empty from dynaconf.utils.parse_conf import parse_conf_data from dynaconf.utils.parse_conf import unparse_conf_data from dynaconf.validator import ValidationError from dynaconf.validator import Validator from dynaconf.vendor import click from dynaconf.vendor import toml from dynaconf.vendor import tomllib os.environ["PYTHONIOENCODING"] = "utf-8" CWD = None try: CWD = Path.cwd() except FileNotFoundError: pass EXTS = ["ini", "toml", "yaml", "json", "py", "env"] WRITERS = ["ini", "toml", "yaml", "json", "py", "redis", "vault", "env"] ENC = default_settings.ENCODING_FOR_DYNACONF def set_settings(ctx, instance=None): """Pick correct settings instance and set it to a global variable.""" global settings settings = None _echo_enabled = ctx.invoked_subcommand not in ["get", None] if instance is not None: if ctx.invoked_subcommand in ["init"]: raise click.UsageError( "-i/--instance option is not allowed for `init` command" ) sys.path.insert(0, ".") settings = import_settings(instance) elif "FLASK_APP" in os.environ: # pragma: no cover with suppress(ImportError, click.UsageError): from flask.cli import ScriptInfo # noqa from dynaconf import FlaskDynaconf flask_app = ScriptInfo().load_app() settings = FlaskDynaconf(flask_app, **flask_app.config).settings _echo_enabled and click.echo( click.style( "Flask app detected", fg="white", bg="bright_black" ) ) elif "DJANGO_SETTINGS_MODULE" in os.environ: # pragma: no cover sys.path.insert(0, os.path.abspath(os.getcwd())) try: # Django extension v2 from django.conf import settings # noqa settings.DYNACONF.configure() except AttributeError: settings = LazySettings() if settings is not None: _echo_enabled and click.echo( click.style( "Django app detected", fg="white", bg="bright_black" ) ) if settings is None: if instance is None and "--help" not in click.get_os_args(): if ctx.invoked_subcommand and ctx.invoked_subcommand not in [ "init", ]: warnings.warn( "Starting on 3.x the param --instance/-i is now required. " "try passing it `dynaconf -i path.to.settings ` " "Example `dynaconf -i config.settings list` " ) settings = legacy_settings else: settings = LazySettings(create_new_settings=True) else: settings = LazySettings() def import_settings(dotted_path): """Import settings instance from python dotted path. Last item in dotted path must be settings instance. Example: import_settings('path.to.settings') """ if "." in dotted_path: module, name = dotted_path.rsplit(".", 1) else: raise click.UsageError( f"invalid path to settings instance: {dotted_path}" ) try: module = importlib.import_module(module) except ImportError as e: raise click.UsageError(e) except FileNotFoundError: return try: return getattr(module, name) except AttributeError as e: raise click.UsageError(e) def split_vars(_vars): """Splits values like foo=bar=zaz in {'foo': 'bar=zaz'}""" return ( { upperfy(k.strip()): parse_conf_data( v.strip(), tomlfy=True, box_settings=settings ) for k, _, v in [item.partition("=") for item in _vars] } if _vars else {} ) def read_file_in_root_directory(*names, **kwargs): """Read a file on root dir.""" return read_file( os.path.join(os.path.dirname(__file__), *names), encoding=kwargs.get("encoding", "utf-8"), ) def print_version(ctx, param, value): if not value or ctx.resilient_parsing: return click.echo(read_file_in_root_directory("VERSION")) ctx.exit() def open_docs(ctx, param, value): # pragma: no cover if not value or ctx.resilient_parsing: return url = "https://dynaconf.com/" webbrowser.open(url, new=2) click.echo(f"{url} opened in browser") ctx.exit() def show_banner(ctx, param, value): """Shows dynaconf awesome banner""" if not value or ctx.resilient_parsing: return set_settings(ctx) click.echo(settings.dynaconf_banner) click.echo("Learn more at: http://github.com/dynaconf/dynaconf") ctx.exit() @click.group() @click.option( "--version", is_flag=True, callback=print_version, expose_value=False, is_eager=True, help="Show dynaconf version", ) @click.option( "--docs", is_flag=True, callback=open_docs, expose_value=False, is_eager=True, help="Open documentation in browser", ) @click.option( "--banner", is_flag=True, callback=show_banner, expose_value=False, is_eager=True, help="Show awesome banner", ) @click.option( "--instance", "-i", default=None, envvar="INSTANCE_FOR_DYNACONF", help="Custom instance of LazySettings", ) @click.pass_context def main(ctx, instance): """Dynaconf - Command Line Interface\n Documentation: https://dynaconf.com/ """ set_settings(ctx, instance) @main.command() @click.option( "--format", "fileformat", "-f", default="toml", type=click.Choice(EXTS) ) @click.option( "--path", "-p", default=CWD, help="defaults to current directory" ) @click.option( "--env", "-e", default=None, help="deprecated command (kept for compatibility but unused)", ) @click.option( "--vars", "_vars", "-v", multiple=True, default=None, help=( "extra values to write to settings file " "e.g: `dynaconf init -v NAME=foo -v X=2`" ), ) @click.option( "--secrets", "_secrets", "-s", multiple=True, default=None, help=( "secret key values to be written in .secrets " "e.g: `dynaconf init -s TOKEN=kdslmflds" ), ) @click.option("--wg/--no-wg", default=True) @click.option("-y", default=False, is_flag=True) @click.option("--django", default=os.environ.get("DJANGO_SETTINGS_MODULE")) @click.pass_context def init(ctx, fileformat, path, env, _vars, _secrets, wg, y, django): """Inits a dynaconf project By default it creates a settings.toml and a .secrets.toml for [default|development|staging|testing|production|global] envs. The format of the files can be changed passing --format=yaml|json|ini|py. This command must run on the project's root folder or you must pass --path=/myproject/root/folder. The --env/-e is deprecated (kept for compatibility but unused) """ click.echo("⚙️ Configuring your Dynaconf environment") click.echo("-" * 42) if "FLASK_APP" in os.environ: # pragma: no cover click.echo( "⚠️ Flask detected, you can't use `dynaconf init` " "on a flask project, instead go to dynaconf.com/flask/ " "for more information.\n" "Or add the following to your app.py\n" "\n" "from dynaconf import FlaskDynaconf\n" "app = Flask(__name__)\n" "FlaskDynaconf(app)\n" ) exit(1) path = Path(path) if env is not None: click.secho( "⚠️ The --env/-e option is deprecated (kept for\n" " compatibility but unused)\n", fg="red", bold=True, # stderr=True, ) if settings.get("create_new_settings") is True: filename = Path("config.py") if not filename.exists(): with open(filename, "w") as new_settings: new_settings.write( constants.INSTANCE_TEMPLATE.format( settings_files=[ f"settings.{fileformat}", f".secrets.{fileformat}", ] ) ) click.echo( "🐍 The file `config.py` was generated.\n" " on your code now use `from config import settings`.\n" " (you must have `config` importable in your PYTHONPATH).\n" ) else: click.echo( f"⁉️ You already have a {filename} so it is not going to be\n" " generated for you, you will need to create your own \n" " settings instance e.g: config.py \n" " from dynaconf import Dynaconf \n" " settings = Dynaconf(**options)\n" ) sys.path.append(str(path)) set_settings(ctx, "config.settings") env = settings.current_env.lower() loader = importlib.import_module(f"dynaconf.loaders.{fileformat}_loader") # Turn foo=bar=zaz in {'foo': 'bar=zaz'} env_data = split_vars(_vars) _secrets = split_vars(_secrets) # create placeholder data for every env settings_data = {} secrets_data = {} if env_data: settings_data[env] = env_data settings_data["default"] = {k: "a default value" for k in env_data} if _secrets: secrets_data[env] = _secrets secrets_data["default"] = {k: "a default value" for k in _secrets} if str(path).endswith( constants.ALL_EXTENSIONS + ("py",) ): # pragma: no cover # noqa settings_path = path secrets_path = path.parent / f".secrets.{fileformat}" gitignore_path = path.parent / ".gitignore" else: if fileformat == "env": if str(path) in (".env", "./.env"): # pragma: no cover settings_path = path elif str(path).endswith("/.env"): # pragma: no cover settings_path = path elif str(path).endswith(".env"): # pragma: no cover settings_path = path.parent / ".env" else: settings_path = path / ".env" Path.touch(settings_path) secrets_path = None else: settings_path = path / f"settings.{fileformat}" secrets_path = path / f".secrets.{fileformat}" gitignore_path = path / ".gitignore" if fileformat in ["py", "env"] or env == "main": # for Main env, Python and .env formats writes a single env settings_data = settings_data.get(env, {}) secrets_data = secrets_data.get(env, {}) if not y and settings_path and settings_path.exists(): # pragma: no cover click.confirm( f"⁉ {settings_path} exists do you want to overwrite it?", abort=True, ) if not y and secrets_path and secrets_path.exists(): # pragma: no cover click.confirm( f"⁉ {secrets_path} exists do you want to overwrite it?", abort=True, ) if settings_path: loader.write(settings_path, settings_data, merge=True) click.echo( f"🎛️ {settings_path.name} created to hold your settings.\n" ) if secrets_path: loader.write(secrets_path, secrets_data, merge=True) click.echo(f"🔑 {secrets_path.name} created to hold your secrets.\n") ignore_line = ".secrets.*" comment = "\n# Ignore dynaconf secret files\n" if not gitignore_path.exists(): with open(str(gitignore_path), "w", encoding=ENC) as f: f.writelines([comment, ignore_line, "\n"]) else: existing = ( ignore_line in open(str(gitignore_path), encoding=ENC).read() ) if not existing: # pragma: no cover with open(str(gitignore_path), "a+", encoding=ENC) as f: f.writelines([comment, ignore_line, "\n"]) click.echo( f"🙈 the {secrets_path.name} is also included in `.gitignore` \n" " beware to not push your secrets to a public repo \n" " or use dynaconf builtin support for Vault Servers.\n" ) if django: # pragma: no cover dj_module, _ = get_module({}, django) dj_filename = dj_module.__file__ if Path(dj_filename).exists(): click.confirm( f"⁉ {dj_filename} is found do you want to add dynaconf?", abort=True, ) with open(dj_filename, "a") as dj_file: dj_file.write(constants.DJANGO_PATCH) click.echo("🎠 Now your Django settings are managed by Dynaconf") else: click.echo("❌ Django settings file not written.") else: click.echo( "🎉 Dynaconf is configured! read more on https://dynaconf.com\n" " Use `dynaconf -i config.settings list` to see your settings\n" ) @main.command(name="get") @click.argument("key", required=True) @click.option( "--default", "-d", default=empty, help="Default value if settings doesn't exist", ) @click.option( "--env", "-e", default=None, help="Filters the env to get the values" ) @click.option( "--unparse", "-u", default=False, help="Unparse data by adding markers such as @none, @int etc..", is_flag=True, ) def get(key, default, env, unparse): """Returns the raw value for a settings key. If result is a dict, list or tuple it is printes as a valid json string. """ if env: env = env.strip() if key: key = key.strip() if env: settings.setenv(env) if default is not empty: result = settings.get(key, default) else: result = settings[key] # let the keyerror raises if unparse: result = unparse_conf_data(result) if isinstance(result, (dict, list, tuple)): result = json.dumps(result, sort_keys=True) click.echo(result, nl=False) @main.command(name="list") @click.option( "--env", "-e", default=None, help="Filters the env to get the values" ) @click.option("--key", "-k", default=None, help="Filters a single key") @click.option( "--more", "-m", default=None, help="Pagination more|less style", is_flag=True, ) @click.option( "--loader", "-l", default=None, help="a loader identifier to filter e.g: toml|yaml", ) @click.option( "--all", "_all", "-a", default=False, is_flag=True, help="show dynaconf internal settings?", ) @click.option( "--output", "-o", type=click.Path(writable=True, dir_okay=False), default=None, help="Filepath to write the listed values as json", ) @click.option( "--output-flat", "flat", is_flag=True, default=False, help="Output file is flat (do not include [env] name)", ) def _list(env, key, more, loader, _all=False, output=None, flat=False): """Lists all user defined config values and if `--all` is passed it also shows dynaconf internal variables. """ if env: env = env.strip() if key: key = key.strip() if loader: loader = loader.strip() if env: settings.setenv(env) cur_env = settings.current_env.lower() if cur_env == "main": flat = True click.echo( click.style( f"Working in {cur_env} environment ", bold=True, bg="bright_blue", fg="bright_white", ) ) if not loader: data = settings.as_dict(env=env, internal=_all) else: identifier = f"{loader}_{cur_env}" data = settings._loaded_by_loaders.get(identifier, {}) data = data or settings._loaded_by_loaders.get(loader, {}) # remove to avoid displaying twice data.pop("SETTINGS_MODULE", None) def color(_k): if _k in dir(default_settings): return "blue" return "magenta" def format_setting(_k, _v): key = click.style(_k, bg=color(_k), fg="bright_white") data_type = click.style( f"<{type(_v).__name__}>", bg="bright_black", fg="bright_white" ) value = pprint.pformat(_v) return f"{key}{data_type} {value}" if not key: datalines = "\n".join( format_setting(k, v) for k, v in data.items() if k not in data.get("RENAMED_VARS", []) ) (click.echo_via_pager if more else click.echo)(datalines) if output: loaders.write(output, data, env=not flat and cur_env) else: key = upperfy(key) try: value = settings.get(key, empty) except AttributeError: value = empty if value is empty: click.echo(click.style("Key not found", bg="red", fg="white")) return click.echo(format_setting(key, value)) if output: loaders.write(output, {key: value}, env=not flat and cur_env) if env: settings.setenv() @main.command() @click.argument("to", required=True, type=click.Choice(WRITERS)) @click.option( "--vars", "_vars", "-v", multiple=True, default=None, help=( "key values to be written " "e.g: `dynaconf write toml -e NAME=foo -e X=2" ), ) @click.option( "--secrets", "_secrets", "-s", multiple=True, default=None, help=( "secret key values to be written in .secrets " "e.g: `dynaconf write toml -s TOKEN=kdslmflds -s X=2" ), ) @click.option( "--path", "-p", default=CWD, help="defaults to current directory/settings.{ext}", ) @click.option( "--env", "-e", default="default", help=( "env to write to defaults to DEVELOPMENT for files " "for external sources like Redis and Vault " "it will be DYNACONF or the value set in " "$ENVVAR_PREFIX_FOR_DYNACONF" ), ) @click.option("-y", default=False, is_flag=True) def write(to, _vars, _secrets, path, env, y): """Writes data to specific source""" _vars = split_vars(_vars) _secrets = split_vars(_secrets) loader = importlib.import_module(f"dynaconf.loaders.{to}_loader") if to in EXTS: # Lets write to a file path = Path(path) if str(path).endswith(constants.ALL_EXTENSIONS + ("py",)): settings_path = path secrets_path = path.parent / f".secrets.{to}" else: if to == "env": if str(path) in (".env", "./.env"): # pragma: no cover settings_path = path elif str(path).endswith("/.env"): settings_path = path elif str(path).endswith(".env"): settings_path = path.parent / ".env" else: settings_path = path / ".env" Path.touch(settings_path) secrets_path = None _vars.update(_secrets) else: settings_path = path / f"settings.{to}" secrets_path = path / f".secrets.{to}" if ( _vars and not y and settings_path and settings_path.exists() ): # pragma: no cover # noqa click.confirm( f"{settings_path} exists do you want to overwrite it?", abort=True, ) if ( _secrets and not y and secrets_path and secrets_path.exists() ): # pragma: no cover # noqa click.confirm( f"{secrets_path} exists do you want to overwrite it?", abort=True, ) if to not in ["py", "env"]: if _vars: _vars = {env: _vars} if _secrets: _secrets = {env: _secrets} if _vars and settings_path: loader.write(settings_path, _vars, merge=True) click.echo(f"Data successful written to {settings_path}") if _secrets and secrets_path: loader.write(secrets_path, _secrets, merge=True) click.echo(f"Data successful written to {secrets_path}") else: # pragma: no cover # lets write to external source with settings.using_env(env): # make sure we're in the correct environment loader.write(settings, _vars, **_secrets) click.echo(f"Data successful written to {to}") @main.command() @click.option( "--path", "-p", default=CWD, help="defaults to current directory" ) def validate(path): # pragma: no cover """Validates Dynaconf settings based on rules defined in dynaconf_validators.toml""" # reads the 'dynaconf_validators.toml' from path # for each section register the validator for specific env # call validate path = Path(path) if not str(path).endswith(".toml"): path = path / "dynaconf_validators.toml" if not path.exists(): # pragma: no cover # noqa click.echo(click.style(f"{path} not found", fg="white", bg="red")) sys.exit(1) try: # try tomlib first validation_data = tomllib.load(open(str(path), "rb")) except UnicodeDecodeError: # fallback to legacy toml (TBR in 4.0.0) warnings.warn( "TOML files should have only UTF-8 encoded characters. " "starting on 4.0.0 dynaconf will stop allowing invalid chars.", ) validation_data = toml.load( open(str(path), encoding=default_settings.ENCODING_FOR_DYNACONF), ) success = True for env, name_data in validation_data.items(): for name, data in name_data.items(): if not isinstance(data, dict): # pragma: no cover click.echo( click.style( f"Invalid rule for parameter '{name}'", fg="white", bg="yellow", ) ) else: data.setdefault("env", env) click.echo( click.style( f"Validating '{name}' with '{data}'", fg="white", bg="blue", ) ) try: Validator(name, **data).validate(settings) except ValidationError as e: click.echo( click.style(f"Error: {e}", fg="white", bg="red") ) success = False if success: click.echo(click.style("Validation success!", fg="white", bg="green")) else: click.echo(click.style("Validation error!", fg="white", bg="red")) sys.exit(1) if __name__ == "__main__": # pragma: no cover main()