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 typing import TYPE_CHECKING 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 if TYPE_CHECKING: # pragma: no cover from dynaconf.base import Settings os.environ["PYTHONIOENCODING"] = "utf-8" CWD = None with suppress(FileNotFoundError): CWD = Path.cwd() 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", "inspect", 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 app_import_path = os.environ["FLASK_APP"] flask_app = ScriptInfo(app_import_path).load_app() settings = FlaskDynaconf(flask_app, **flask_app.config).settings if _echo_enabled: 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 import dynaconf import django # see https://docs.djangoproject.com/en/4.2/ref/applications/ # at #troubleshooting django.setup() settings.DYNACONF.configure() except AttributeError: settings = LazySettings() if settings is not None and _echo_enabled: 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: try: result = settings[key] except KeyError: click.echo("Key not found", nl=False, err=True) sys.exit(1) 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 user defined settings or all (including internal configs). By default, shows only user defined. If `--all` is passed it also shows dynaconf internal variables aswell. """ 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.secho("Key not found", bg="red", fg="white", err=True) 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 provided rules. Rules should be 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) # parse validator file 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), ) except tomllib.TOMLDecodeError as e: click.echo( click.style( f"Error parsing TOML: {e}. Maybe it should be quoted.", fg="white", bg="red", ) ) sys.exit(1) # guarantee there is an environment validation_data = {k.lower(): v for k, v in validation_data.items()} if not validation_data.get("default"): validation_data = {"default": validation_data} 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}'" "(this will be skipped)", 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) from dynaconf.utils.inspect import ( KeyNotFoundError, builtin_dumpers, inspect_settings, EnvNotFoundError, OutputFormatError, ) INSPECT_FORMATS = list(builtin_dumpers.keys()) @main.command() @click.option("--key", "-k", help="Filters result by key.") @click.option( "--env", "-e", help="Filters result by environment.", default=None ) @click.option( "--format", "-f", help="The output format.", default="json", type=click.Choice(INSPECT_FORMATS), ) @click.option( "--old-first", "new_first", "-s", help="Invert history sorting to 'old-first'", default=True, is_flag=True, ) @click.option( "--limit", "history_limit", "-n", default=None, type=int, help="Limits how many history entries are shown.", ) @click.option( "--all", "_all", "-a", default=False, is_flag=True, help="Show dynaconf internal settings?", ) def inspect( key, env, format, new_first, history_limit, _all ): # pragma: no cover """ Inspect the loading history of the given settings instance. Filters by key and environement, otherwise shows all. """ try: inspect_settings( settings, key=key, env=env or None, dumper=format, new_first=new_first, include_internal=_all, history_limit=history_limit, print_report=True, ) click.echo() except (KeyNotFoundError, EnvNotFoundError, OutputFormatError) as err: click.echo(err) sys.exit(1) if __name__ == "__main__": # pragma: no cover main()