diff options
Diffstat (limited to 'libs/dynaconf/cli.py')
-rw-r--r-- | libs/dynaconf/cli.py | 773 |
1 files changed, 773 insertions, 0 deletions
diff --git a/libs/dynaconf/cli.py b/libs/dynaconf/cli.py new file mode 100644 index 000000000..8b8ab5d53 --- /dev/null +++ b/libs/dynaconf/cli.py @@ -0,0 +1,773 @@ +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 <cmd>` " + "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() + + + "--version", + is_flag=True, + callback=print_version, + expose_value=False, + is_eager=True, + help="Show dynaconf version", +) + "--docs", + is_flag=True, + callback=open_docs, + expose_value=False, + is_eager=True, + help="Open documentation in browser", +) + "--banner", + is_flag=True, + callback=show_banner, + expose_value=False, + is_eager=True, + help="Show awesome banner", +) + "--instance", + "-i", + default=None, + envvar="INSTANCE_FOR_DYNACONF", + help="Custom instance of LazySettings", +) [email protected]_context +def main(ctx, instance): + """Dynaconf - Command Line Interface\n + Documentation: https://dynaconf.com/ + """ + set_settings(ctx, instance) + + + "--format", "fileformat", "-f", default="toml", type=click.Choice(EXTS) +) + "--path", "-p", default=CWD, help="defaults to current directory" +) + "--env", + "-e", + default=None, + help="deprecated command (kept for compatibility but unused)", +) + "--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`" + ), +) + "--secrets", + "_secrets", + "-s", + multiple=True, + default=None, + help=( + "secret key values to be written in .secrets " + "e.g: `dynaconf init -s TOKEN=kdslmflds" + ), +) [email protected]("--wg/--no-wg", default=True) [email protected]("-y", default=False, is_flag=True) [email protected]("--django", default=os.environ.get("DJANGO_SETTINGS_MODULE")) [email protected]_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" + ) + + [email protected](name="get") [email protected]("key", required=True) + "--default", + "-d", + default=empty, + help="Default value if settings doesn't exist", +) + "--env", "-e", default=None, help="Filters the env to get the values" +) + "--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) + + [email protected](name="list") + "--env", "-e", default=None, help="Filters the env to get the values" +) [email protected]("--key", "-k", default=None, help="Filters a single key") + "--more", + "-m", + default=None, + help="Pagination more|less style", + is_flag=True, +) + "--loader", + "-l", + default=None, + help="a loader identifier to filter e.g: toml|yaml", +) + "--all", + "_all", + "-a", + default=False, + is_flag=True, + help="show dynaconf internal settings?", +) + "--output", + "-o", + type=click.Path(writable=True, dir_okay=False), + default=None, + help="Filepath to write the listed values as json", +) + "--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() + + [email protected]("to", required=True, type=click.Choice(WRITERS)) + "--vars", + "_vars", + "-v", + multiple=True, + default=None, + help=( + "key values to be written " + "e.g: `dynaconf write toml -e NAME=foo -e X=2" + ), +) + "--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" + ), +) + "--path", + "-p", + default=CWD, + help="defaults to current directory/settings.{ext}", +) + "--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" + ), +) [email protected]("-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}") + + + "--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() |